From e654065a722c9a47407fbef0f934f9f09b1f5bc3 Mon Sep 17 00:00:00 2001 From: lbehr Date: Sun, 2 Feb 2025 08:08:03 -0700 Subject: [PATCH 1/2] initial checkin --- .../Sections/RateLimitRulesConfiguration.cs | 8 + .../Sections/RateLimiterConfiguration.cs | 8 + .../ServiceCollectionExtensions.cs | 30 +++ .../ServiceProviderExtensions.cs | 22 ++ .../Constants/ResultConstants.cs | 7 + .../Crexi.RateLimiter.Rule.csproj | 31 +++ .../Execution/IRuleEvaluationLogic.cs | 9 + .../Execution/RateLimitEngine.cs | 90 +++++++ .../Execution/RuleEvaluationLogic.cs | 46 ++++ .../Extensions/RateLimitRuleExtensions.cs | 14 + .../ResourceAccess/RateLimitResourceAccess.cs | 73 ++++++ .../Utility/CallDataComparer.cs | 20 ++ .../Utility/FullRateLimitRuleComparer.cs | 35 +++ .../Utility/UpdateRateLimitRuleComparer.cs | 35 +++ .../Validation/RateLimitRuleValidator.cs | 42 +++ .../Crexi.RateLimiter.Test.csproj | 47 ++++ .../RuleEvaluationLogicTests.cs | 156 +++++++++++ .../RuleLimitEngineTests.cs | 213 +++++++++++++++ Crexi.RateLimiter.Test/RuleValidationTests.cs | 243 ++++++++++++++++++ .../ShallowMocks/MockEndpointDataSource.cs | 21 ++ .../TestBase/ServiceCollectionExtensions.cs | 12 + .../TestBase/TestClassBase.cs | 55 ++++ Crexi.RateLimiter.Test/appsettings.json | 28 ++ ...Crexi.RateLimiter.Rule.Abstractions.csproj | 15 ++ .../Enum/EvaluationType.cs | 9 + .../Execution/IRateLimitEngine.cs | 9 + .../Model/CallData.cs | 9 + .../Model/CallHistory.cs | 7 + .../Model/RateLimitRule.cs | 59 +++++ .../IRateLimitResourceAccess.cs | 12 + .../Utility/IContextParser.cs | 9 + RateLimiter.Tests/RateLimiter.Tests.csproj | 3 - RateLimiter.sln | 36 ++- crexi.ReadMe | 88 +++++++ me.ReadMe | 33 +++ 35 files changed, 1517 insertions(+), 17 deletions(-) create mode 100644 Crexi.RateLimiter.Rule/Configuration/Sections/RateLimitRulesConfiguration.cs create mode 100644 Crexi.RateLimiter.Rule/Configuration/Sections/RateLimiterConfiguration.cs create mode 100644 Crexi.RateLimiter.Rule/Configuration/ServiceCollectionExtensions.cs create mode 100644 Crexi.RateLimiter.Rule/Configuration/ServiceProviderExtensions.cs create mode 100644 Crexi.RateLimiter.Rule/Constants/ResultConstants.cs create mode 100644 Crexi.RateLimiter.Rule/Crexi.RateLimiter.Rule.csproj create mode 100644 Crexi.RateLimiter.Rule/Execution/IRuleEvaluationLogic.cs create mode 100644 Crexi.RateLimiter.Rule/Execution/RateLimitEngine.cs create mode 100644 Crexi.RateLimiter.Rule/Execution/RuleEvaluationLogic.cs create mode 100644 Crexi.RateLimiter.Rule/Extensions/RateLimitRuleExtensions.cs create mode 100644 Crexi.RateLimiter.Rule/ResourceAccess/RateLimitResourceAccess.cs create mode 100644 Crexi.RateLimiter.Rule/Utility/CallDataComparer.cs create mode 100644 Crexi.RateLimiter.Rule/Utility/FullRateLimitRuleComparer.cs create mode 100644 Crexi.RateLimiter.Rule/Utility/UpdateRateLimitRuleComparer.cs create mode 100644 Crexi.RateLimiter.Rule/Validation/RateLimitRuleValidator.cs create mode 100644 Crexi.RateLimiter.Test/Crexi.RateLimiter.Test.csproj create mode 100644 Crexi.RateLimiter.Test/RuleEvaluationLogicTests.cs create mode 100644 Crexi.RateLimiter.Test/RuleLimitEngineTests.cs create mode 100644 Crexi.RateLimiter.Test/RuleValidationTests.cs create mode 100644 Crexi.RateLimiter.Test/ShallowMocks/MockEndpointDataSource.cs create mode 100644 Crexi.RateLimiter.Test/TestBase/ServiceCollectionExtensions.cs create mode 100644 Crexi.RateLimiter.Test/TestBase/TestClassBase.cs create mode 100644 Crexi.RateLimiter.Test/appsettings.json create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/Crexi.RateLimiter.Rule.Abstractions.csproj create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/Enum/EvaluationType.cs create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/Execution/IRateLimitEngine.cs create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/Model/CallData.cs create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/Model/CallHistory.cs create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/Model/RateLimitRule.cs create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/ResourceAccess/IRateLimitResourceAccess.cs create mode 100644 Crexi.Ratelimiter.Rule.Abstractions/Utility/IContextParser.cs create mode 100644 crexi.ReadMe create mode 100644 me.ReadMe diff --git a/Crexi.RateLimiter.Rule/Configuration/Sections/RateLimitRulesConfiguration.cs b/Crexi.RateLimiter.Rule/Configuration/Sections/RateLimitRulesConfiguration.cs new file mode 100644 index 00000000..7cb2b4a9 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Configuration/Sections/RateLimitRulesConfiguration.cs @@ -0,0 +1,8 @@ +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.Configuration.Sections; + +public class RateLimitRulesConfiguration +{ + public IEnumerable? StartupRules { get; set; } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Configuration/Sections/RateLimiterConfiguration.cs b/Crexi.RateLimiter.Rule/Configuration/Sections/RateLimiterConfiguration.cs new file mode 100644 index 00000000..8559e8be --- /dev/null +++ b/Crexi.RateLimiter.Rule/Configuration/Sections/RateLimiterConfiguration.cs @@ -0,0 +1,8 @@ +namespace Crexi.RateLimiter.Rule.Configuration.Sections; + +public sealed class RateLimiterConfiguration +{ + public long MaxTimeSpanMinutes { get; set; } + public bool UnrecognizedEvaluationTypeEvaluationResult { get; set; } + public int? UnrecognizedEvaluationTypeOverrideResponseCode { get; set; } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Configuration/ServiceCollectionExtensions.cs b/Crexi.RateLimiter.Rule/Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..c0e2885c --- /dev/null +++ b/Crexi.RateLimiter.Rule/Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Crexi.RateLimiter.Rule.Configuration.Sections; +using Crexi.RateLimiter.Rule.Execution; +using Crexi.RateLimiter.Rule.Model; +using Crexi.RateLimiter.Rule.Validation; +using FluentValidation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Crexi.RateLimiter.Rule.Configuration; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers the local objects to run. + /// NOTE: Has an unregistered dependencies on TimeProvider and MemoryCache. + /// + /// + /// + /// + public static IServiceCollection ConfigureRateLimiterRules(this IServiceCollection services, + IConfiguration configuration) + { + return services + .Configure(configuration.GetSection(key: "RateLimiter")) + .Configure(configuration.GetSection(key: "RateLimiterRules")) + .AddScoped, RateLimitRuleValidator>() + .AddScoped() + .AddScoped(); + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Configuration/ServiceProviderExtensions.cs b/Crexi.RateLimiter.Rule/Configuration/ServiceProviderExtensions.cs new file mode 100644 index 00000000..9693310f --- /dev/null +++ b/Crexi.RateLimiter.Rule/Configuration/ServiceProviderExtensions.cs @@ -0,0 +1,22 @@ +using Crexi.RateLimiter.Rule.Configuration.Sections; +using Crexi.RateLimiter.Rule.Execution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Crexi.RateLimiter.Rule.Configuration; + +public static class ServiceProviderExtensions +{ + /// + /// Registers rules from the configuration, if any + /// Should be called after app is started + /// + /// + public static void RegisterStartupRules(this IServiceProvider serviceProvider) + { + var ruleConfiguration = serviceProvider.GetRequiredService>()?.Value; + if (ruleConfiguration?.StartupRules is null) return; + var engine = serviceProvider.GetRequiredService(); + engine.AddUpdateRules(ruleConfiguration.StartupRules); + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Constants/ResultConstants.cs b/Crexi.RateLimiter.Rule/Constants/ResultConstants.cs new file mode 100644 index 00000000..8f2eae73 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Constants/ResultConstants.cs @@ -0,0 +1,7 @@ +namespace Crexi.RateLimiter.Rule.Constants; + +internal class ResultConstants +{ + internal const int DefaultFailureResponseCode = 429; + internal static readonly (bool success, int? responseCode) SuccessResponse = (true, null); +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Crexi.RateLimiter.Rule.csproj b/Crexi.RateLimiter.Rule/Crexi.RateLimiter.Rule.csproj new file mode 100644 index 00000000..7b037828 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Crexi.RateLimiter.Rule.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Crexi.RateLimiter.Rule/Execution/IRuleEvaluationLogic.cs b/Crexi.RateLimiter.Rule/Execution/IRuleEvaluationLogic.cs new file mode 100644 index 00000000..0a86e2c1 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Execution/IRuleEvaluationLogic.cs @@ -0,0 +1,9 @@ +using Crexi.RateLimiter.Rule.Enum; +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.Execution; + +public interface IRuleEvaluationLogic +{ + (bool success, int? responseCode) EvaluateRule(EvaluationType evaluationType, TimeSpan window, int? maxCallCount, int? overrideResponseCode, CallHistory history); +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Execution/RateLimitEngine.cs b/Crexi.RateLimiter.Rule/Execution/RateLimitEngine.cs new file mode 100644 index 00000000..d3a08a02 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Execution/RateLimitEngine.cs @@ -0,0 +1,90 @@ +using Crexi.RateLimiter.Rule.Constants; +using Crexi.RateLimiter.Rule.Extensions; +using Crexi.RateLimiter.Rule.Model; +using Crexi.RateLimiter.Rule.ResourceAccess; +using Crexi.RateLimiter.Rule.Utility; + +namespace Crexi.RateLimiter.Rule.Execution; + +public class RateLimitEngine(IRateLimitResourceAccess resourceAccess, TimeProvider timeProvider, IRuleEvaluationLogic logic): IRateLimitEngine +{ + public void AddUpdateRules(IEnumerable rules) + { + var comparer = new UpdateRateLimitRuleComparer(); + var ruleSets = rules.GroupBy(r => r.ToCallData(), new CallDataComparer()); + foreach (var ruleSet in ruleSets) + { + var existingRuleDictionary = resourceAccess.GetRules(ruleSet.Key)? + .ToDictionary(comparer.GetHashCode) ?? new Dictionary(); + foreach (var rule in ruleSet) + { + var hash = comparer.GetHashCode(rule); + existingRuleDictionary[hash] = rule; + } + resourceAccess.SetRules(existingRuleDictionary.Values, ruleSet.Key); + resourceAccess.SetExpirationWindow(TimeSpan.FromMilliseconds(existingRuleDictionary.Values.Max(r => r.Timespan)), ruleSet.Key); + } + } + + public (bool success, int? responseCode) Evaluate(CallData callData) + { + var rules = GetMostSpecificRulesForCallData(callData); + if (rules is null || rules.Count == 0) return ResultConstants.SuccessResponse; + var callHistory = resourceAccess.AddCallAndGetHistory(callData); + return EvaluateRules(rules, callHistory); + } + + #region private methods + + private IList? GetMostSpecificRulesForCallData(CallData callData) + { + IList? rules; + do + { + rules = resourceAccess.GetRules(callData); + } while (rules is null && TryDeSpecifyCallDataByOne(callData)); + return rules; + } + + private static bool TryDeSpecifyCallDataByOne(CallData callData) + { + if (callData.RegionId.HasValue) + { + callData.RegionId = null; + return true; + } + if (callData.TierId.HasValue) + { + callData.TierId = null; + return true; + } + if (callData.ClientId.HasValue) + { + callData.ClientId = null; + return true; + } + return false; + } + + private (bool success, int? responseCode) EvaluateRules(IList rules, CallHistory callHistory) + { + foreach (var rule in rules) + { + if (!IsInEffectiveWindow(rule)) continue; + var result = logic.EvaluateRule(rule.EvaluationType, TimeSpan.FromMilliseconds(rule.Timespan), rule.MaxCallCount, rule.OverrideResponseCode, callHistory); + if (!result.success) + return (false, rule.OverrideResponseCode ?? ResultConstants.DefaultFailureResponseCode); + } + return ResultConstants.SuccessResponse; + } + + private bool IsInEffectiveWindow(RateLimitRule rule) + { + if (rule.EffectiveWindowStartUtc is null) + return true; + var currentUtc = TimeOnly.FromDateTime(timeProvider.GetUtcNow().DateTime); + return rule.EffectiveWindowStartUtc <= currentUtc && rule.EffectiveWindowEndUtc >= currentUtc; + } + + #endregion private methods +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Execution/RuleEvaluationLogic.cs b/Crexi.RateLimiter.Rule/Execution/RuleEvaluationLogic.cs new file mode 100644 index 00000000..4557fe9d --- /dev/null +++ b/Crexi.RateLimiter.Rule/Execution/RuleEvaluationLogic.cs @@ -0,0 +1,46 @@ +using Crexi.RateLimiter.Rule.Configuration.Sections; +using Crexi.RateLimiter.Rule.Constants; +using Crexi.RateLimiter.Rule.Enum; +using Crexi.RateLimiter.Rule.Model; +using Microsoft.Extensions.Options; + +namespace Crexi.RateLimiter.Rule.Execution; + +public class RuleEvaluationLogic(TimeProvider timeProvider, IOptions configuration) : IRuleEvaluationLogic +{ + public (bool success, int? responseCode) EvaluateRule(EvaluationType evaluationType, TimeSpan window, int? maxCallCount, int? overrideResponseCode, CallHistory history) => evaluationType switch + { + EvaluationType.TimespanSinceLastCall => EvaluateTimespanSinceLastCall(window, overrideResponseCode, history), + EvaluationType.CallsDuringTimespan => EvaluateCallsDuringTimespan(window, maxCallCount, overrideResponseCode, history), + EvaluationType.WeAreTeasing => EvaluateWeAreTeasing(), + EvaluationType.WeArePseudoConfusing => EvaluateWeArePseudoConfusing(), + _ => configuration.Value.UnrecognizedEvaluationTypeEvaluationResult ? ResultConstants.SuccessResponse : (false, configuration.Value.UnrecognizedEvaluationTypeOverrideResponseCode ?? ResultConstants.DefaultFailureResponseCode) + }; + + private (bool success, int? responseCode) EvaluateTimespanSinceLastCall(TimeSpan window, + int? overrideResponseCode, + CallHistory history) + { + var result = + timeProvider.GetUtcNow().DateTime.Subtract(window) > history.LastCall + ? ResultConstants.SuccessResponse + : (false, overrideResponseCode ?? ResultConstants.DefaultFailureResponseCode); + return result; + } + + private (bool success, int? responseCode) EvaluateCallsDuringTimespan(TimeSpan window, int? maxCallCount, int? overrideResponseCode, CallHistory history) + { + if (history.Calls is null || history.Calls.Length == 0) return ResultConstants.SuccessResponse; + var measure = timeProvider.GetUtcNow().DateTime.Subtract(window); + var windowCallCount = history.Calls.Count(c => c >= measure); + /* + NOTE: not handling null call count. I'd handle it via config values in a real implementation, but here assuming rule validation will prevent errors here. + */ + return windowCallCount < maxCallCount! ? ResultConstants.SuccessResponse + : (false, overrideResponseCode ?? ResultConstants.DefaultFailureResponseCode); + } + + private static (bool success, int? responseCode) EvaluateWeAreTeasing() => (false, 406); + + private static (bool success, int? responseCode) EvaluateWeArePseudoConfusing() => (new Random().Next() % 2 == 0, 418); +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Extensions/RateLimitRuleExtensions.cs b/Crexi.RateLimiter.Rule/Extensions/RateLimitRuleExtensions.cs new file mode 100644 index 00000000..a60db466 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Extensions/RateLimitRuleExtensions.cs @@ -0,0 +1,14 @@ +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.Extensions; + +public static class RateLimitRuleExtensions +{ + public static CallData ToCallData(this RateLimitRule rule) => new() + { + RegionId = rule.RegionId, + TierId = rule.TierId, + ClientId = rule.ClientId, + Resource = rule.Resource, + }; +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/ResourceAccess/RateLimitResourceAccess.cs b/Crexi.RateLimiter.Rule/ResourceAccess/RateLimitResourceAccess.cs new file mode 100644 index 00000000..5cbce8e8 --- /dev/null +++ b/Crexi.RateLimiter.Rule/ResourceAccess/RateLimitResourceAccess.cs @@ -0,0 +1,73 @@ +using Crexi.RateLimiter.Rule.Model; +using Crexi.RateLimiter.Rule.Utility; +using Microsoft.Extensions.Caching.Memory; + +namespace Crexi.RateLimiter.Rule.ResourceAccess; + +public class RateLimitResourceAccess(IMemoryCache memoryCache, TimeProvider timeProvider): IRateLimitResourceAccess +{ + private readonly CallDataComparer _callDataComparer = new(); + private const string CacheOptionSuffix = "_CacheOption"; + private const string RulesSuffix = "_Rules"; + + public CallHistory AddCallAndGetHistory(CallData callData) + { + var hash = _callDataComparer.GetHashCode(callData); + var callUtc = timeProvider.GetUtcNow().DateTime; + var calls = new List() { callUtc }; + DateTime? latestCall = null; + /* + NOTE: I fully understand this is almost certainly breaking, but for the stated purpose of the test (We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm), I'm going to pretend it will never break... + */ + foreach (var key in ((MemoryCache)memoryCache).Keys) + { + if (key is not string strKey || !strKey.StartsWith($"{hash}_Call_")) + continue; + var value = memoryCache.Get(key); + calls.Add(value); + latestCall = latestCall is null ? value : Max(latestCall.Value, value); + } + AddNewCallToCache(hash, callUtc); + return new CallHistory() + { + Calls = calls.ToArray(), + LastCall = latestCall, + }; + } + + public IList? GetRules(CallData callData) + { + var hash = _callDataComparer.GetHashCode(callData); + return memoryCache.TryGetValue>($"{hash}_{RulesSuffix}", out var rules) ? rules : null; + } + + public IRateLimitResourceAccess SetExpirationWindow(TimeSpan timespan, CallData callData) + { + var hash = _callDataComparer.GetHashCode(callData); + memoryCache.Set($"{hash}_{CacheOptionSuffix}", new MemoryCacheEntryOptions() + { + AbsoluteExpirationRelativeToNow = timespan + }); + return this; + } + + public IRateLimitResourceAccess SetRules(IEnumerable rules, CallData callData) + { + var hash = _callDataComparer.GetHashCode(callData); + /* + NOTE: Since rules shouldn't expire or get evicted, MemoryCache really isn't the right storage, but as I said before, for this test, I'll pretend it's functional. + */ + memoryCache.Set($"{hash}_{CacheOptionSuffix}", rules); + return this; + } + + private void AddNewCallToCache(int callDataHash, DateTime callUtc) + { + var callKey = $"{callDataHash}_Call_{Guid.NewGuid()}"; + var options = memoryCache.Get($"{callDataHash}_{CacheOptionSuffix}"); + memoryCache.Set(callKey, callUtc, options); + } + + private static DateTime Max(DateTime dt1, DateTime dt2) => + dt1 > dt2 ? dt1 : dt2; +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Utility/CallDataComparer.cs b/Crexi.RateLimiter.Rule/Utility/CallDataComparer.cs new file mode 100644 index 00000000..a3bd4b11 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Utility/CallDataComparer.cs @@ -0,0 +1,20 @@ +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.Utility; + +public class CallDataComparer: IEqualityComparer +{ + public bool Equals(CallData? x, CallData? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null) return false; + if (y is null) return false; + if (x.GetType() != y.GetType()) return false; + return x.RegionId == y.RegionId && x.TierId == y.TierId && x.ClientId == y.ClientId && x.Resource == y.Resource; + } + + public int GetHashCode(CallData obj) + { + return HashCode.Combine(obj.RegionId, obj.TierId, obj.ClientId, obj.Resource); + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Utility/FullRateLimitRuleComparer.cs b/Crexi.RateLimiter.Rule/Utility/FullRateLimitRuleComparer.cs new file mode 100644 index 00000000..843351ff --- /dev/null +++ b/Crexi.RateLimiter.Rule/Utility/FullRateLimitRuleComparer.cs @@ -0,0 +1,35 @@ +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.Utility; + +public class FullRateLimitRuleComparer : IEqualityComparer +{ + public bool Equals(RateLimitRule? x, RateLimitRule? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null) return false; + if (y is null) return false; + if (x.GetType() != y.GetType()) return false; + return x.RegionId == y.RegionId && x.TierId == y.TierId && x.ClientId == y.ClientId && + x.Resource == y.Resource && x.Timespan == y.Timespan && x.MaxCallCount == y.MaxCallCount && + x.EvaluationType == y.EvaluationType && x.OverrideResponseCode == y.OverrideResponseCode && + Nullable.Equals(x.EffectiveWindowStartUtc, y.EffectiveWindowStartUtc) && + Nullable.Equals(x.EffectiveWindowEndUtc, y.EffectiveWindowEndUtc); + } + + public int GetHashCode(RateLimitRule obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.RegionId); + hashCode.Add(obj.TierId); + hashCode.Add(obj.ClientId); + hashCode.Add(obj.Resource); + hashCode.Add(obj.Timespan); + hashCode.Add(obj.MaxCallCount); + hashCode.Add((int)obj.EvaluationType); + hashCode.Add(obj.OverrideResponseCode); + hashCode.Add(obj.EffectiveWindowStartUtc); + hashCode.Add(obj.EffectiveWindowEndUtc); + return hashCode.ToHashCode(); + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Utility/UpdateRateLimitRuleComparer.cs b/Crexi.RateLimiter.Rule/Utility/UpdateRateLimitRuleComparer.cs new file mode 100644 index 00000000..0d620ec0 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Utility/UpdateRateLimitRuleComparer.cs @@ -0,0 +1,35 @@ +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.Utility; + +/// +/// Compares rules on non-variable identifying fields +/// +public class UpdateRateLimitRuleComparer : IEqualityComparer +{ + /* + NOTE: this is really the logic for what can get updated. Namely: + Timespan, MaxCallCount, OverrideResponseCode, EffectiveWindowStartUtc, EffectiveWindowEndUtc + Arguable this hides the logic a bit, but for what we're doing here I'm running with it. + */ + public bool Equals(RateLimitRule? x, RateLimitRule? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null) return false; + if (y is null) return false; + if (x.GetType() != y.GetType()) return false; + return x.RegionId == y.RegionId && x.TierId == y.TierId && x.ClientId == y.ClientId && + x.Resource == y.Resource && x.EvaluationType == y.EvaluationType; + } + + public int GetHashCode(RateLimitRule obj) + { + var hashCode = new HashCode(); + hashCode.Add(obj.RegionId); + hashCode.Add(obj.TierId); + hashCode.Add(obj.ClientId); + hashCode.Add(obj.Resource); + hashCode.Add((int)obj.EvaluationType); + return hashCode.ToHashCode(); + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Rule/Validation/RateLimitRuleValidator.cs b/Crexi.RateLimiter.Rule/Validation/RateLimitRuleValidator.cs new file mode 100644 index 00000000..ecfd6bd6 --- /dev/null +++ b/Crexi.RateLimiter.Rule/Validation/RateLimitRuleValidator.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using Microsoft.AspNetCore.Routing; +using Crexi.RateLimiter.Rule.Enum; +using Crexi.RateLimiter.Rule.Model; +using Microsoft.Extensions.Options; +using Crexi.RateLimiter.Rule.Configuration.Sections; + +namespace Crexi.RateLimiter.Rule.Validation +{ + public class RateLimitRuleValidator : AbstractValidator + { + public RateLimitRuleValidator(IEnumerable endpoints, IOptions options) + { + RuleFor(r => r.Resource) + .NotEmpty().WithMessage("Must provide a target resource.") + .Must(r => endpoints.Any(e => e.Endpoints.Any(ee => ee.DisplayName == r))).WithMessage("Not a valid resource for this service."); + RuleFor(r => r.Timespan) + .Must(t => t < options.Value.MaxTimeSpanMinutes * 60000) + .WithMessage($"Requested timespan exceeds the max configured limit of {options.Value.MaxTimeSpanMinutes} minutes."); + RuleFor(r => r.MaxCallCount) + .NotNull() + .When(r => r.EvaluationType == EvaluationType.CallsDuringTimespan) + .WithMessage("Must provide max calls when evaluating CallsDuringTimespan."); + /* + * NOTE: No "> 0" rule for timespan. Allowing for a "no access" rule by setting timespan to 0. + * Given field is required and non-nullable, this introduces the potential for user error. + * For the purposes here, assuming only smart, cautious people will use this to set rules. + */ + RuleFor(r => r.EvaluationType) + .NotEmpty() + .Must(et => System.Enum.IsDefined(typeof(EvaluationType), et)) + .WithMessage("Must provide a valid evaluation type."); + RuleFor(r => r.EffectiveWindowStartUtc) + .NotNull().When(r => r.EffectiveWindowEndUtc is not null).WithMessage("EffectiveWindowStartUtc must be provided when EffectiveWindowEndUtc is provided."); + RuleFor(r => r.EffectiveWindowEndUtc) + .NotNull().When(r => r.EffectiveWindowStartUtc is not null).WithMessage("EffectiveWindowEndUtc must be provided when EffectiveWindowStartUtc is provided."); + RuleFor(r => r.EffectiveWindowStartUtc) + .Must((r, windowStart) => windowStart < r.EffectiveWindowEndUtc).When(r => r.EffectiveWindowStartUtc is not null && r.EffectiveWindowEndUtc is not null) + .WithMessage("EffectiveWindowStartUtc must be before EffectiveWindowEndUtc."); + } + } +} diff --git a/Crexi.RateLimiter.Test/Crexi.RateLimiter.Test.csproj b/Crexi.RateLimiter.Test/Crexi.RateLimiter.Test.csproj new file mode 100644 index 00000000..696d68ef --- /dev/null +++ b/Crexi.RateLimiter.Test/Crexi.RateLimiter.Test.csproj @@ -0,0 +1,47 @@ + + + + net8.0 + enable + enable + + false + true + + + + + PreserveNewest + true + PreserveNewest + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/Crexi.RateLimiter.Test/RuleEvaluationLogicTests.cs b/Crexi.RateLimiter.Test/RuleEvaluationLogicTests.cs new file mode 100644 index 00000000..96b54de4 --- /dev/null +++ b/Crexi.RateLimiter.Test/RuleEvaluationLogicTests.cs @@ -0,0 +1,156 @@ +using Crexi.RateLimiter.Rule.Configuration; +using Crexi.RateLimiter.Rule.Enum; +using Crexi.RateLimiter.Rule.Execution; +using Crexi.RateLimiter.Rule.Model; +using Crexi.RateLimiter.Test.TestBase; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; + +namespace Crexi.RateLimiter.Test +{ + public class RuleEvaluationLogicTests: TestClassBase + { + #region CallsDuringTimespan + + [Fact] + public void CallsDuringTimespanUnderMaxCalls() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.CallsDuringTimespan, TimeSpan.FromMilliseconds(30), 6, null, + CallHistoryFiveCallsInLast30Ms(timeProvider)); + Assert.True(success); + Assert.Null(responseCode); + } + + [Fact] + public void CallsDuringTimespanEqualMaxCalls() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.CallsDuringTimespan, TimeSpan.FromMilliseconds(30), 5, OverrideResponseCode, + CallHistoryFiveCallsInLast30Ms(timeProvider)); + Assert.False(success); + Assert.Equal(OverrideResponseCode, responseCode); + } + + [Fact] + public void CallsDuringTimespanOverMaxCalls() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.CallsDuringTimespan, TimeSpan.FromMilliseconds(30), 4, OverrideResponseCode, + CallHistoryFiveCallsInLast30Ms(timeProvider)); + Assert.False(success); + Assert.Equal(OverrideResponseCode, responseCode); + } + + #endregion CallsDuringTimespan + + #region TimespanSinceLastCall + + [Fact] + public void TimespanSinceLastCallUnderWindow() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.TimespanSinceLastCall, TimeSpan.FromMilliseconds(31), null, OverrideResponseCode, + CallHistoryLastCall30MsAgo(timeProvider)); + Assert.False(success); + Assert.Equal(OverrideResponseCode, responseCode); + } + + [Fact] + public void TimespanSinceLastCallInWindow() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.TimespanSinceLastCall, TimeSpan.FromMilliseconds(30), null, OverrideResponseCode, + CallHistoryLastCall30MsAgo(timeProvider)); + Assert.False(success); + Assert.Equal(OverrideResponseCode, responseCode); + } + + [Fact] + public void TimespanSinceLastCallOverWindow() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.TimespanSinceLastCall, TimeSpan.FromMilliseconds(29), null, null, + CallHistoryLastCall30MsAgo(timeProvider)); + Assert.True(success); + Assert.Null(responseCode); + } + + #endregion TimespanSinceLastCall + + #region WeAreTeasing + + [Fact] + public void WeAreTeasing() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.WeAreTeasing, TimeSpan.FromMilliseconds(31), null, OverrideResponseCode, + CallHistoryLastCall30MsAgo(timeProvider)); + Assert.False(success); + Assert.Equal(406, responseCode); + } + + #endregion WeAreTeasing + + #region WeArePseudoConfusing + + [Fact] + public void WeArePseudoConfusing() + { + var logic = GetService(); + var timeProvider = GetService(); + var (success, responseCode) = logic.EvaluateRule(EvaluationType.WeAreTeasing, TimeSpan.FromMilliseconds(31), null, OverrideResponseCode, + CallHistoryLastCall30MsAgo(timeProvider)); + Assert.Equal(406, responseCode); + } + + #endregion WeArePseudoConfusing + + #region data + + private const int OverrideResponseCode = 1; + + private static CallHistory CallHistoryFiveCallsInLast30Ms(TimeProvider timeProvider) => new() + { + Calls = [ timeProvider.GetUtcNow().DateTime, timeProvider.GetUtcNow().DateTime, timeProvider.GetUtcNow().DateTime, timeProvider.GetUtcNow().DateTime, timeProvider.GetUtcNow().DateTime, timeProvider.GetUtcNow().DateTime.Subtract(TimeSpan.FromMilliseconds(31)), ] + }; + + private static CallHistory CallHistoryLastCall30MsAgo(TimeProvider timeProvider) => new() + { + LastCall = timeProvider.GetUtcNow().DateTime.Subtract(TimeSpan.FromMilliseconds(30)) + }; + + #endregion data + + + #region configuration + + protected override void AddConfigurations(IConfigurationBuilder builder) + { + builder.AddJsonFile("appsettings.json"); + } + + protected override IServiceCollection ConfigureServices(HostBuilderContext context, IServiceCollection services) => + services + .ConfigureRateLimiterRules(context.Configuration) + .AddSingleton(); + + protected override IServiceCollection ConfigureServices(IServiceCollection services) => + services; + + + protected override IServiceProvider ConfigureProviders(IServiceProvider serviceProvider) => + serviceProvider; + + #endregion configuration + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Test/RuleLimitEngineTests.cs b/Crexi.RateLimiter.Test/RuleLimitEngineTests.cs new file mode 100644 index 00000000..67bd239d --- /dev/null +++ b/Crexi.RateLimiter.Test/RuleLimitEngineTests.cs @@ -0,0 +1,213 @@ +using Crexi.RateLimiter.Rule.Configuration.Sections; +using Crexi.RateLimiter.Rule.Enum; +using Crexi.RateLimiter.Rule.Execution; +using Crexi.RateLimiter.Rule.Model; +using Crexi.RateLimiter.Rule.ResourceAccess; +using Crexi.RateLimiter.Rule.Utility; +using Crexi.RateLimiter.Rule.Validation; +using Crexi.RateLimiter.Test.TestBase; +using FluentValidation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Moq; + +namespace Crexi.RateLimiter.Test +{ + public class RuleLimitEngineTests : TestClassBase + { + #region AddUpdateRules + + [Fact] + public void AddNewRules() + { + using var scope = TestHost.Services.CreateScope(); + SetupLogic(scope.ServiceProvider); + var startupRules = scope.ServiceProvider + .GetRequiredService>() + .Value.StartupRules!.ToList(); + var mockAccess = SetupResourceAccess(scope.ServiceProvider, []); + mockAccess.Setup(a => a.SetRules(It.IsAny>(), It.IsAny())) + .Returns((IEnumerable rules, CallData callData) => + { + var callHash = CallDataComparer.GetHashCode(callData); + Assert.True(_expectedNewRules.TryGetValue(callHash, out var expectedRules)); + Assert.Equal(expectedRules, rules, RuleComparer.Equals); + return mockAccess.Object; + }); + var engine = scope.ServiceProvider.GetService(); + engine!.AddUpdateRules(startupRules); + } + + [Fact] + public void AddUpdateExistingRules() + { + using var scope = TestHost.Services.CreateScope(); + SetupLogic(scope.ServiceProvider); + var mockAccess = SetupResourceAccess(scope.ServiceProvider); + mockAccess.Setup(a => a.SetRules(It.IsAny>(), It.IsAny())) + .Returns((IEnumerable rules, CallData callData) => + { + var callHash = CallDataComparer.GetHashCode(callData); + Assert.True(_expectedUpdatedRules.TryGetValue(callHash, out var expectedRules)); + var orderedExpected = expectedRules.OrderBy(RuleComparer.GetHashCode); + var orderedRules = rules!.OrderBy(RuleComparer.GetHashCode); + Assert.Equal(orderedExpected, orderedRules, RuleComparer.Equals); + return mockAccess.Object; + }); + var engine = scope.ServiceProvider.GetService(); + engine!.AddUpdateRules(_updateRules); + } + + [Fact] + public void SlidingWindowIsMaxOfRules() + { + using var scope = TestHost.Services.CreateScope(); + SetupLogic(scope.ServiceProvider); + var mockAccess = SetupResourceAccess(scope.ServiceProvider, []); + mockAccess.Setup(a => a.SetRules(It.IsAny>(), It.IsAny())) + .Returns(mockAccess.Object); + mockAccess.Setup(a => a.SetExpirationWindow(It.IsAny(), It.IsAny())) + .Returns((TimeSpan ts, CallData cd) => + { + Assert.Equal(TimeSpan.FromMilliseconds(70), ts); + return mockAccess.Object; + }); + var engine = scope.ServiceProvider.GetService(); + engine!.AddUpdateRules(_slidingWindowRules); + } + + #endregion AddUpdateRules + + #region Evaluate + + [Fact] + public void EvaluateWillDeSpecifyUntilMatchIsFound() + { + ValueTuple expectedResult = (true, null); + using var scope = TestHost.Services.CreateScope(); + var mockLogic = SetupLogic(scope.ServiceProvider); + mockLogic.Setup(l => l.EvaluateRule(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns((EvaluationType evaluationType, TimeSpan window, int? maxCallCount, int? overrideResponseCode, + CallHistory history) => + { + if (evaluationType != EvaluationType.TimespanSinceLastCall || + window != TimeSpan.FromMilliseconds(5000) || + maxCallCount.HasValue || overrideResponseCode.HasValue) + throw new Exception("Crud - we messed up"); + return expectedResult; + }); + var mockAccess = SetupResourceAccessForEvaluate(scope.ServiceProvider); + mockAccess.Setup(a => a.AddCallAndGetHistory(It.IsAny())) + .Returns(new CallHistory()); + var engine = scope.ServiceProvider.GetService(); + var result = engine!.Evaluate(_overSpecificBaseCallData); + Assert.Equal(expectedResult, result); + } + + #endregion Evaluate + + #region data + + private static readonly CallDataComparer CallDataComparer = new(); + private static readonly FullRateLimitRuleComparer RuleComparer = new(); + private const string TestPost = "HTTP: POST /test"; + private readonly CallData _baseCallData = new() { Resource = TestPost }; + private readonly CallData _overSpecificBaseCallData = new() { RegionId = 7, TierId = 2, ClientId = 3, Resource = TestPost }; + private readonly Dictionary> _expectedNewRules = new() + { + { CallDataComparer.GetHashCode(new CallData() { Resource = TestPost }), [ new RateLimitRule() { Resource = TestPost, Timespan = 5000, EvaluationType = EvaluationType.TimespanSinceLastCall }] }, + { CallDataComparer.GetHashCode(new CallData() { RegionId = 1, Resource = TestPost }), [ new RateLimitRule() { RegionId = 1, Resource = TestPost, Timespan = 5000, EvaluationType = EvaluationType.CallsDuringTimespan }] } + }; + private readonly IList _slidingWindowRules = + [ + new() { Resource = TestPost, Timespan = 50, EvaluationType = EvaluationType.TimespanSinceLastCall }, + new() { Resource = TestPost, Timespan = 60, EvaluationType = EvaluationType.CallsDuringTimespan }, + new() { Resource = TestPost, Timespan = 70, EvaluationType = EvaluationType.CallsDuringTimespan }, + ]; + + private readonly IList _updateRules = + [ + new() { Resource = TestPost, Timespan = 3000, EvaluationType = EvaluationType.TimespanSinceLastCall }, + new() { RegionId = 1, Resource = TestPost, Timespan = 77, EvaluationType = EvaluationType.TimespanSinceLastCall } + ]; + private readonly Dictionary> _expectedUpdatedRules = new() + { + { CallDataComparer.GetHashCode(new CallData() { Resource = TestPost }), + [ + new RateLimitRule() { Resource = TestPost, Timespan = 3000, EvaluationType = EvaluationType.TimespanSinceLastCall } + ] + }, + { CallDataComparer.GetHashCode(new CallData() { RegionId = 1, Resource = TestPost }), + [ + new RateLimitRule() { RegionId = 1, Resource = TestPost, Timespan = 5000, EvaluationType = EvaluationType.CallsDuringTimespan }, + new RateLimitRule() { RegionId = 1, Resource = TestPost, Timespan = 77, EvaluationType = EvaluationType.TimespanSinceLastCall }, + ] + } + }; + + #endregion data + + #region setup + + private Mock SetupLogic(IServiceProvider serviceProvider) + { + var mockLogic = serviceProvider.GetService>(); + return mockLogic!; + } + + private Mock SetupResourceAccess(IServiceProvider serviceProvider, IList? getRules = null) + { + var mockResource = serviceProvider.GetService>(); + Assert.NotNull(mockResource); + mockResource.Setup(r => r.GetRules(It.IsAny())) + .Returns((CallData cd) => getRules ?? _expectedNewRules[CallDataComparer.GetHashCode(cd)]); + return mockResource; + } + + private Mock SetupResourceAccessForEvaluate(IServiceProvider serviceProvider) + { + var mostDeSpecificHashCode = CallDataComparer.GetHashCode(_baseCallData); + var mockResource = serviceProvider.GetService>(); + Assert.NotNull(mockResource); + mockResource.Setup(r => r.GetRules(It.IsAny())) + .Returns((CallData cd) => CallDataComparer.Equals(cd, _baseCallData) + ? _expectedNewRules[mostDeSpecificHashCode] + : null); + return mockResource; + } + + #endregion setup + + #region configuration + + protected override void AddConfigurations(IConfigurationBuilder builder) + { + builder.AddJsonFile("appsettings.json"); + } + + protected override IServiceCollection ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + return services + .Configure(context.Configuration.GetSection(key: "RateLimiter")) + .Configure(context.Configuration.GetSection(key: "RateLimiterRules")) + .AddScoped, RateLimitRuleValidator>() + .AddScopedMock() + .AddScopedMock() + .AddScoped() + .AddSingleton(); + } + + protected override IServiceCollection ConfigureServices(IServiceCollection services) => + services; + + + protected override IServiceProvider ConfigureProviders(IServiceProvider serviceProvider) => + serviceProvider; + + #endregion configuration + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Test/RuleValidationTests.cs b/Crexi.RateLimiter.Test/RuleValidationTests.cs new file mode 100644 index 00000000..b12624f9 --- /dev/null +++ b/Crexi.RateLimiter.Test/RuleValidationTests.cs @@ -0,0 +1,243 @@ +using Crexi.RateLimiter.Rule.Configuration; +using Crexi.RateLimiter.Rule.Enum; +using Crexi.RateLimiter.Rule.Model; +using Crexi.RateLimiter.Test.ShallowMocks; +using Crexi.RateLimiter.Test.TestBase; +using FluentValidation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; + +namespace Crexi.RateLimiter.Test +{ + public class RuleValidationTests: TestClassBase + { + private readonly IEnumerable _endpointDataSources = [ new MockEndpointDataSource() ]; + + #region Resource + + [Fact] + public void ResourceCannotBeEmpty() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = string.Empty, + Timespan = 100, + EvaluationType = EvaluationType.TimespanSinceLastCall, + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "Must provide a target resource.")); + } + + [Fact] + public void ResourceMustBeRegistered() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: GET /another/service/endpoint", + Timespan = 100, + EvaluationType = EvaluationType.TimespanSinceLastCall, + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "Not a valid resource for this service.")); + } + + [Fact] + public void ValidResourcePasses() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 100, + EvaluationType = EvaluationType.TimespanSinceLastCall, + }; + var result = validator.Validate(rule); + Assert.True(result.IsValid); + } + + #endregion Resource + + #region TimeSpan + + [Fact] + public void TimeSpanMustBeLessThanConfiguredLimit() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 300001, + EvaluationType = EvaluationType.CallsDuringTimespan, + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "Requested timespan exceeds the max configured limit of 5 minutes.")); + } + + [Fact] + public void TimeSpanIsLessThanConfiguredLimit() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 299999, + EvaluationType = EvaluationType.TimespanSinceLastCall, + }; + var result = validator.Validate(rule); + Assert.True(result.IsValid); + } + + #endregion TimeSpan + + #region TimeSpan + + [Fact] + public void MaxCallCountNotRequiredForTimespanSinceLastCall() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 1, + EvaluationType = EvaluationType.TimespanSinceLastCall, + }; + var result = validator.Validate(rule); + Assert.True(result.IsValid); + } + + [Fact] + public void MaxCallCountRequiredForCallsDuringTimespan() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 300001, + EvaluationType = EvaluationType.CallsDuringTimespan, + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "Must provide max calls when evaluating CallsDuringTimespan.")); + } + + [Fact] + public void MaxCallCountForCallsDuringTimespan() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 1, + EvaluationType = EvaluationType.CallsDuringTimespan, + MaxCallCount = 5, + }; + var result = validator.Validate(rule); + Assert.True(result.IsValid); + } + + #endregion TimeSpan + + #region EvaluationType + + [Fact] + public void EvaluationTypeMustExist() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 100, + EvaluationType = default, + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "Must provide a valid evaluation type.")); + } + + #endregion EvaluationType + + #region EffectiveWindow + + [Fact] + public void EffectiveWindowBothValuesMustBeProvided_Start() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 100, + EvaluationType = EvaluationType.TimespanSinceLastCall, + EffectiveWindowStartUtc = new TimeOnly(1, 19) + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "EffectiveWindowEndUtc must be provided when EffectiveWindowStartUtc is provided.")); + } + + [Fact] + public void EffectiveWindowBothValuesMustBeProvided_End() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 100, + EvaluationType = EvaluationType.TimespanSinceLastCall, + EffectiveWindowEndUtc = new TimeOnly(1, 19) + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "EffectiveWindowStartUtc must be provided when EffectiveWindowEndUtc is provided.")); + } + + [Fact] + public void EffectiveWindowStartMustBeBeforeEnd() + { + var validator = GetService>(); + var rule = new RateLimitRule() + { + Resource = "HTTP: POST /test", + Timespan = 100, + EvaluationType = default, + EffectiveWindowStartUtc = new TimeOnly(23, 19), + EffectiveWindowEndUtc = new TimeOnly(1, 19) + }; + var result = validator.Validate(rule); + Assert.False(result.IsValid); + Assert.True(result.Errors.Exists(e => e.ErrorMessage == "EffectiveWindowStartUtc must be before EffectiveWindowEndUtc.")); + } + + #endregion EffectiveWindow + + + #region configuration + + protected override void AddConfigurations(IConfigurationBuilder builder) + { + builder.AddJsonFile("appsettings.json"); + } + + protected override IServiceCollection ConfigureServices(HostBuilderContext context, IServiceCollection services) => + services + .ConfigureRateLimiterRules(context.Configuration) + .AddSingleton() + .AddScoped(_ => _endpointDataSources); + + protected override IServiceCollection ConfigureServices(IServiceCollection services) => + services; + + + protected override IServiceProvider ConfigureProviders(IServiceProvider serviceProvider) => + serviceProvider; + + #endregion configuration + } +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Test/ShallowMocks/MockEndpointDataSource.cs b/Crexi.RateLimiter.Test/ShallowMocks/MockEndpointDataSource.cs new file mode 100644 index 00000000..93bd31dd --- /dev/null +++ b/Crexi.RateLimiter.Test/ShallowMocks/MockEndpointDataSource.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; + +namespace Crexi.RateLimiter.Test.ShallowMocks; + +public class MockEndpointDataSource: EndpointDataSource +{ + public override IChangeToken GetChangeToken() + { + throw new NotImplementedException(); + } + + public override IReadOnlyList Endpoints => + [ + new(null, null, "HTTP: GET /test/{id}"), + new(null, null, "HTTP: POST /test"), + new(null, null, "HTTP: PUT /test"), + new(null, null, "HTTP: DELETE /test/{id}"), + ]; +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Test/TestBase/ServiceCollectionExtensions.cs b/Crexi.RateLimiter.Test/TestBase/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..9db55408 --- /dev/null +++ b/Crexi.RateLimiter.Test/TestBase/ServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Crexi.RateLimiter.Test.TestBase; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddScopedMock(this IServiceCollection services) where T : class => + services + .AddScoped>(_ => new Mock()) + .AddScoped(p => p.GetRequiredService>().Object); +} \ No newline at end of file diff --git a/Crexi.RateLimiter.Test/TestBase/TestClassBase.cs b/Crexi.RateLimiter.Test/TestBase/TestClassBase.cs new file mode 100644 index 00000000..8bfdbcd2 --- /dev/null +++ b/Crexi.RateLimiter.Test/TestBase/TestClassBase.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Crexi.RateLimiter.Test.TestBase +{ + public abstract class TestClassBase + { + protected readonly IHost TestHost; + private readonly Task _hostTask; + + protected IServiceProvider ServiceProvider => TestHost.Services; + + protected TestClassBase() + { + TestHost = Host.CreateDefaultBuilder() + .ConfigureAppConfiguration(AddConfigurations) + .ConfigureLogging(AddLogging) + .ConfigureServices((context, services) => + { + InternalConfigureServices(services); + ConfigureServices(context, services); + ConfigureServices(services); + }) + .Build(); + InternalConfigureProviders(TestHost.Services); + ConfigureProviders(TestHost.Services); + _hostTask = TestHost.RunAsync(); + } + + public static readonly ILoggerFactory ConsoleLoggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + + protected T GetService() where T : notnull => ServiceProvider.GetRequiredService(); + + protected virtual void AddLogging(ILoggingBuilder builder) => builder.AddDebug().AddConsole(); + + protected virtual void AddConfigurations(IConfigurationBuilder builder) { } + + protected virtual IServiceCollection InternalConfigureServices(IServiceCollection services) => services; + + protected abstract IServiceCollection ConfigureServices(IServiceCollection services); + + protected virtual IServiceCollection ConfigureServices(HostBuilderContext context, IServiceCollection services) => services; + + protected abstract IServiceProvider ConfigureProviders(IServiceProvider serviceProvider); + + protected virtual IServiceProvider InternalConfigureProviders(IServiceProvider serviceProvider) => serviceProvider; + + ~TestClassBase() + { + _hostTask.Dispose(); + } + } +} diff --git a/Crexi.RateLimiter.Test/appsettings.json b/Crexi.RateLimiter.Test/appsettings.json new file mode 100644 index 00000000..a487f5a5 --- /dev/null +++ b/Crexi.RateLimiter.Test/appsettings.json @@ -0,0 +1,28 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "RateLimiter": { + "MaxTimeSpanMinutes": 5, + "UnrecognizedEvaluationTypeEvaluationResult": true, + }, + "RateLimiterRules": { + "StartupRules": [ + { + "Resource": "HTTP: POST /test", + "Timespan": 5000, + "EvaluationType": "TimespanSinceLastCall" + }, + { + "RegionId": 1, + "Resource": "HTTP: POST /test", + "Timespan": 5000, + "EvaluationType": "CallsDuringTimespan" + } + ] + } +} diff --git a/Crexi.Ratelimiter.Rule.Abstractions/Crexi.RateLimiter.Rule.Abstractions.csproj b/Crexi.Ratelimiter.Rule.Abstractions/Crexi.RateLimiter.Rule.Abstractions.csproj new file mode 100644 index 00000000..0036f1e8 --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/Crexi.RateLimiter.Rule.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + $(MSBuildProjectName.Replace(".Abstractions","")) + + + + + + + + diff --git a/Crexi.Ratelimiter.Rule.Abstractions/Enum/EvaluationType.cs b/Crexi.Ratelimiter.Rule.Abstractions/Enum/EvaluationType.cs new file mode 100644 index 00000000..6042999e --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/Enum/EvaluationType.cs @@ -0,0 +1,9 @@ +namespace Crexi.RateLimiter.Rule.Enum; + +public enum EvaluationType +{ + TimespanSinceLastCall = 1, + CallsDuringTimespan = 2, + WeAreTeasing = 3, + WeArePseudoConfusing = 4, +} \ No newline at end of file diff --git a/Crexi.Ratelimiter.Rule.Abstractions/Execution/IRateLimitEngine.cs b/Crexi.Ratelimiter.Rule.Abstractions/Execution/IRateLimitEngine.cs new file mode 100644 index 00000000..dbebe5c6 --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/Execution/IRateLimitEngine.cs @@ -0,0 +1,9 @@ +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.Execution; + +public interface IRateLimitEngine +{ + void AddUpdateRules(IEnumerable rules); + (bool success, int? responseCode) Evaluate(CallData callData); +} \ No newline at end of file diff --git a/Crexi.Ratelimiter.Rule.Abstractions/Model/CallData.cs b/Crexi.Ratelimiter.Rule.Abstractions/Model/CallData.cs new file mode 100644 index 00000000..b1caeab1 --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/Model/CallData.cs @@ -0,0 +1,9 @@ +namespace Crexi.RateLimiter.Rule.Model; + +public class CallData +{ + public int? RegionId { get; set; } + public int? TierId { get; set; } + public int? ClientId { get; set; } + public required string Resource { get; set; } +} \ No newline at end of file diff --git a/Crexi.Ratelimiter.Rule.Abstractions/Model/CallHistory.cs b/Crexi.Ratelimiter.Rule.Abstractions/Model/CallHistory.cs new file mode 100644 index 00000000..a3d4abf1 --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/Model/CallHistory.cs @@ -0,0 +1,7 @@ +namespace Crexi.RateLimiter.Rule.Model; + +public class CallHistory +{ + public DateTime[]? Calls { get; set; } + public DateTime? LastCall { get; set; } +} \ No newline at end of file diff --git a/Crexi.Ratelimiter.Rule.Abstractions/Model/RateLimitRule.cs b/Crexi.Ratelimiter.Rule.Abstractions/Model/RateLimitRule.cs new file mode 100644 index 00000000..614a4974 --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/Model/RateLimitRule.cs @@ -0,0 +1,59 @@ +using Crexi.RateLimiter.Rule.Enum; + +namespace Crexi.RateLimiter.Rule.Model; + +public class RateLimitRule +{ + /// + /// Limits the rule to a specific region + /// + public int? RegionId { get; set; } + + /// + /// Limits the rule to a tier of service + /// + public int? TierId { get; set; } + + /// + /// Limits the rule to a specific client + /// + public int? ClientId { get; set; } + + /// + /// The endpoint the rule applies to + /// Sample expected format: `HTTP: GET /route/{id}` + /// + public required string Resource { get; set; } + + /// + /// TimeSpan in milliseconds to use as the measurement + /// + public required long Timespan { get; set; } + + /// + /// Max number of calls allowed + /// Optional based on Evaluation Type. + /// + public int? MaxCallCount { get; set; } + + /// + /// How the Rule timespan will be evaluated + /// + public required EvaluationType EvaluationType { get; set; } + + /// + /// HttpResponseCode code to return + /// If not provided, the default code will be used + /// + public int? OverrideResponseCode { get; set; } + + /// + /// Defines optional window within which rule is active + /// + public TimeOnly? EffectiveWindowStartUtc { get; set; } + + /// + /// Defines optional window within which rule is active + /// + public TimeOnly? EffectiveWindowEndUtc { get; set; } +} diff --git a/Crexi.Ratelimiter.Rule.Abstractions/ResourceAccess/IRateLimitResourceAccess.cs b/Crexi.Ratelimiter.Rule.Abstractions/ResourceAccess/IRateLimitResourceAccess.cs new file mode 100644 index 00000000..b6ef42d3 --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/ResourceAccess/IRateLimitResourceAccess.cs @@ -0,0 +1,12 @@ +using Crexi.RateLimiter.Rule.Model; + +namespace Crexi.RateLimiter.Rule.ResourceAccess; + +public interface IRateLimitResourceAccess +{ + CallHistory AddCallAndGetHistory(CallData callData); + IList? GetRules(CallData callData); + + IRateLimitResourceAccess SetExpirationWindow(TimeSpan timespan, CallData callData); + IRateLimitResourceAccess SetRules(IEnumerable rules, CallData callData); +} \ No newline at end of file diff --git a/Crexi.Ratelimiter.Rule.Abstractions/Utility/IContextParser.cs b/Crexi.Ratelimiter.Rule.Abstractions/Utility/IContextParser.cs new file mode 100644 index 00000000..33008fb7 --- /dev/null +++ b/Crexi.Ratelimiter.Rule.Abstractions/Utility/IContextParser.cs @@ -0,0 +1,9 @@ +using Crexi.RateLimiter.Rule.Model; +using Microsoft.AspNetCore.Http; + +namespace Crexi.RateLimiter.Rule.Utility; + +public interface IContextParser +{ + CallData GetCallData(HttpContext context); +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..8b6b8696 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -4,9 +4,6 @@ latest enable - - - diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..767e8d11 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,31 +1,39 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.15 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35707.178 d17.12 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}" ProjectSection(SolutionItems) = preProject + crexi.ReadMe = crexi.ReadMe + me.ReadMe = me.ReadMe README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crexi.RateLimiter.Rule.Abstractions", "Crexi.Ratelimiter.Rule.Abstractions\Crexi.RateLimiter.Rule.Abstractions.csproj", "{4DFC0E3A-AF55-44F7-B948-E63F3B50D3AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crexi.RateLimiter.Rule", "Crexi.RateLimiter.Rule\Crexi.RateLimiter.Rule.csproj", "{8BA51825-78CE-4D03-B363-DA7BBDE001F2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crexi.RateLimiter.Test", "Crexi.RateLimiter.Test\Crexi.RateLimiter.Test.csproj", "{0A004FEC-332C-4F68-AD5F-012C1216A803}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.Build.0 = Release|Any CPU - {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.Build.0 = Release|Any CPU + {4DFC0E3A-AF55-44F7-B948-E63F3B50D3AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DFC0E3A-AF55-44F7-B948-E63F3B50D3AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DFC0E3A-AF55-44F7-B948-E63F3B50D3AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DFC0E3A-AF55-44F7-B948-E63F3B50D3AF}.Release|Any CPU.Build.0 = Release|Any CPU + {8BA51825-78CE-4D03-B363-DA7BBDE001F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BA51825-78CE-4D03-B363-DA7BBDE001F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BA51825-78CE-4D03-B363-DA7BBDE001F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BA51825-78CE-4D03-B363-DA7BBDE001F2}.Release|Any CPU.Build.0 = Release|Any CPU + {0A004FEC-332C-4F68-AD5F-012C1216A803}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A004FEC-332C-4F68-AD5F-012C1216A803}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A004FEC-332C-4F68-AD5F-012C1216A803}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A004FEC-332C-4F68-AD5F-012C1216A803}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/crexi.ReadMe b/crexi.ReadMe new file mode 100644 index 00000000..0e186f40 --- /dev/null +++ b/crexi.ReadMe @@ -0,0 +1,88 @@ +Rate-limiting pattern + +Rate limiting involves restricting the number of requests that a client can make. A client is identified with an access token, which is used for every request to a resource. To prevent abuse of the server, APIs enforce rate-limiting techniques. +The rate-limiting application can decide whether to allow the request based on the client. The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit. +If the request is within the limit, then the request goes through. Otherwise, the API call is restricted. + +Some examples of request-limiting rules (you could imagine any others) + + X requests per timespan; + 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. + +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. + +You are welcome to ask any questions regarding the requirements—treat us as product owners, analysts, or whoever knows the business. If you have any questions or concerns, please submit them as a GitHub issue. + +You should fork the project and create a pull request named as FirstName LastName once you are finished. + +Good luck! + +****** +Notes from issues: +Ok got it. So rules are strictly resource based and not client. +Meaning if a rule exists on let's say GetPicture resource, that rule +applies to all clients calling that resource. + +****** +The reason we say no need to add ASP.NET project is moreso to avoid having a lot of the PR adding controllers/endpoints. +In modern times, we'd add https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit?view=aspnetcore-8.0 and be on our way. However, you can add that if you want, because you are correct, it's the most natural use case for this test. + + Does it mean "design of the rule configuration classes" or "design of the implementation (such as efficient rule-matching against endpoints and context data and concurrent and safe rule execution" + +The latter. While you shouldn't ignore the configuration as it's an important piece without ASP.NET there to add attributes as you mentioned, once you have a resource and 1:M rate limit rules, how do you handle it is the design we are interested in. + +****** +No need for this exercise, we can assume an endpoint without a rate limit rule is open for consumption. + +****** + + + Does extensibility mean that new rule types may be introduced? + +Correct. If a product requirement came in for a new rule, how easily can it be introduced. No worries about recompilation. + + So should there be Any( list of rules ) or All ( list of rules ) logic or just any set of rules (but All must satisfy, which is more real-world) + +Any set of rules per endpoint, but all must satisfy. If an endpoint is configured to check >1 rule, ALL must pass. If one fails, the request is rejected. + +***** +As for the environment, great question! Let's assume a single node to keep it simple. + +***** +Great questions! + + How should I handle client identification for rate-limiting purposes? Should I consider different access tokens for the same client or assume one access token per client? + +Let's assume a token uniquely identifies each client. As for the token itself, you're not building the authentication layer, and you don't care how the token was generated; think a client is making a request, and that client has a token and some other associated metadata. + + How should multiple rules be combined? Should they be enforced in a particular order, or should all rules be checked simultaneously for each request? + +The goal is to be able to configure a unique combination of rules for each resource. As long as this is achieved, we're good. For simplicity, we could treat those rules as logical ORs. + + Should I be aware of any specific concurrency or thread safety requirements? + +We assume the resources will be accessed concurrently, as in real APIs, so the access rules should also support that. + +***** +Yes, concurrency should be taken into consideration at some basic level (e.g. if you have shared request log which is accessible from multiple locations). + +As for the memory considerations, just use reasonable in-memory data structures, and it would be fine. For the request log, for instance, do not care about flushing the data to some persistent storage to free up memory, etc. We always can dive deeper here when discussing the code moving forward. + +This task has been mostly intended to give us an example of your thoughts when designing classes, data structures and tests rather than high-load systems, so feel free to keep it simple :) + +***** +Since it is the library specifically for rules, can I assume that the client information is already retrieved through instance of IHttpContextAccessor and injected into this library? +So I should not mainly worry about getting client information from some token rather have a class for Client with all kind of information (like ClientId, Region) but use that class to determine what kind of rules apply. +You got it right. +Basically you build your own representation of a user which makes sense in a context of library you're building. + +***** diff --git a/me.ReadMe b/me.ReadMe new file mode 100644 index 00000000..406be1e4 --- /dev/null +++ b/me.ReadMe @@ -0,0 +1,33 @@ +I spent a little over 8 hours Saturday, then slept on it overnight. As already noted, the cache is not complete - +it's missing tests and the rules cache needs to be implemented separately (for this probably straight concurrentdictionary) +so it's perminant. If I was spending more time, I'd probably throw it into a sqlite db or some other local file based data store +so that any rule changes made during operation could be reloaded if the app would be restarted. +Regardless, I the minimal stuff would take another hour or 2 and I could refine with more time, but I've got to get other things +done this weekend and I believe this shows approach, thought, and general coding style of the moment. Hopefully it is enough to +move forward to a conversation on the whys, hows, and decisions made. Either way I appreciate the challenge and had fun +thinking/woring it through. + +LNB. + +Assumptions + 1. Portability isn't important - this is tied to AspNet and Net8. + 2. ContextParser is defined elsewhere, so will always be mocked in for our purposes. (no implementation) + 3. While rule execution is time critical, adding/updating rules is not time critical. + 4. When getting rules to run, we will get the most specific match (i.e. if no match for requested client-endpoint, but match for endpoint, we'll return the endpoint match) + Essentially adding a region, tier, or client specific ruleset will override any less specific rule sets. + +Notes: + 1. I generally prefer "self-documenting" code, but have comments sprinkled in the code where I wanted to leave a note about a direction I went. + 2. I lumped things into the same projects. Depending on how this would be packaged, I'd probably break them out a bit more, but for the purposes here I figured it works and makes navigation easier. + 3. It might just be my lack of knowledge/imagination, but it seems that there aren't a ton of evaluations to make on rate limiting data, + so this is set up so it's easy to configure the limits, but adding completely new rules is more tied down - requiring dev. + I considered using string rules (at BAM, currently working through a purpose built rule/expression engine borrowing from and inspired by the MS Rules Engine and it's use of dynamic linq). + If the state of that project was a few days further along I might have, but as is, I hope this is sufficient. + 4. The tests are pretty vanilla - no auto data, etc - just enough to demonstrate the code basically works. + 5. Some tests aren't very transparent. See #4 for reason/excuse. + 6. ResourceAccess is probably the weakest portion of this. It was the last done and really the target was function - I dedicated more time to the logic. + Thread safety is a little fragile here, too... + 7. I went overboard adding the rule window. It's a very basic implementation (i.e. logic doesn't support spanning days), but I figured since I was having fun... + + + \ No newline at end of file From eb10ed8b41f836f85b7827fff11248dab10763fd Mon Sep 17 00:00:00 2001 From: lbehr Date: Sun, 2 Feb 2025 08:08:45 -0700 Subject: [PATCH 2/2] remove unused projects --- RateLimiter.Tests/RateLimiter.Tests.csproj | 12 ------------ RateLimiter.Tests/RateLimiterTest.cs | 13 ------------- RateLimiter/RateLimiter.csproj | 7 ------- 3 files changed, 32 deletions(-) delete mode 100644 RateLimiter.Tests/RateLimiter.Tests.csproj delete mode 100644 RateLimiter.Tests/RateLimiterTest.cs delete mode 100644 RateLimiter/RateLimiter.csproj diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj deleted file mode 100644 index 8b6b8696..00000000 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net6.0 - latest - enable - - - - - - - \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs deleted file mode 100644 index 172d44a7..00000000 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj deleted file mode 100644 index 19962f52..00000000 --- a/RateLimiter/RateLimiter.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - net6.0 - latest - enable - - \ No newline at end of file