diff --git a/RateLimiter.Tests/Helpers/ActionFilterHelper.cs b/RateLimiter.Tests/Helpers/ActionFilterHelper.cs new file mode 100644 index 00000000..6b732b49 --- /dev/null +++ b/RateLimiter.Tests/Helpers/ActionFilterHelper.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace RateLimiter.Tests.Helpers; + +public class ActionFilterHelper +{ + public static ActionExecutingContext CreateActionExecutingContext(string token, string actionName) + { + var httpContext = new DefaultHttpContext + { + Request = + { + Headers = + { + new KeyValuePair("AccessToken", token) + } + } + }; + + var actionContext = new ActionContext + { + HttpContext = httpContext, + RouteData = new RouteData(), + ActionDescriptor = new ActionDescriptor + { + DisplayName = actionName + } + }; + + var actionExecutingContext = new ActionExecutingContext( + actionContext, + new List(), + new Dictionary(), + new Mock().Object + ); + + return actionExecutingContext; + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/MinimumTimespanBetweenCallsAttributeTests.cs b/RateLimiter.Tests/MinimumTimespanBetweenCallsAttributeTests.cs new file mode 100644 index 00000000..7793c5e0 --- /dev/null +++ b/RateLimiter.Tests/MinimumTimespanBetweenCallsAttributeTests.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; +using RateLimiter.Attributes; +using RateLimiter.Tests.Helpers; + +namespace RateLimiter.Tests; + +[TestFixture] +public class MinimumTimespanBetweenCallsAttributeTests +{ + [Test] + public void MinimumTimespanBetweenCalls_AllowsRequest_AfterInterval() + { + // Arrange + var filter = new MinimumTimespanBetweenCallsAttribute(5); + var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); + + // Act + filter.OnActionExecuting(context); + System.Threading.Thread.Sleep(5001); // Wait for the interval to pass + filter.OnActionExecuting(context); + + // Assert + Assert.IsNull(context.Result); + } + + [Test] + public void MinimumTimespanBetweenCalls_BlocksRequest_BeforeInterval() + { + // Arrange + var filter = new MinimumTimespanBetweenCallsAttribute(5); + var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); + + // Act + filter.OnActionExecuting(context); + filter.OnActionExecuting(context); // Should be blocked + + // Assert + Assert.IsInstanceOf(context.Result); + Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimitPerTimespanAttributeTests.cs b/RateLimiter.Tests/RateLimitPerTimespanAttributeTests.cs new file mode 100644 index 00000000..bbdba0f7 --- /dev/null +++ b/RateLimiter.Tests/RateLimitPerTimespanAttributeTests.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; +using RateLimiter.Attributes; +using RateLimiter.Tests.Helpers; + +namespace RateLimiter.Tests; + +[TestFixture] +public class RateLimitPerTimespanAttributeTests +{ + [Test] + [TestCase(5)] + public void RateLimitPerTimespan_AllowsRequest_WithinLimit(int maxRequests) + { + // Arrange + var filter = new RateLimitPerTimespanAttribute(maxRequests, 60); + var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); + + for (var i = 0; i < maxRequests; i++) + { + // Act + filter.OnActionExecuting(context); + } + + // Assert + Assert.IsNull(context.Result); + } + + [Test] + public void RateLimitPerTimespan_BlocksRequest_ExceedsLimit() + { + // Arrange + var filter = new RateLimitPerTimespanAttribute(1, 60); + var context = ActionFilterHelper.CreateActionExecutingContext("token", "ActionName"); + + // Act + filter.OnActionExecuting(context); + filter.OnActionExecuting(context); // Should be blocked + + // Assert + Assert.IsInstanceOf(context.Result); + Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..74a39c74 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/RateLimiter.Tests/RegionBasedRateLimitAttributeTests.cs b/RateLimiter.Tests/RegionBasedRateLimitAttributeTests.cs new file mode 100644 index 00000000..e279b21b --- /dev/null +++ b/RateLimiter.Tests/RegionBasedRateLimitAttributeTests.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; +using RateLimiter.Attributes; +using RateLimiter.Tests.Helpers; + +namespace RateLimiter.Tests; + +[TestFixture] +public class RegionBasedRateLimitAttributeTests +{ + [Test] + [TestCase(5)] + public void RegionBasedRateLimit_AllowsUSRequest_WithinLimit(int maxRequests) + { + // Arrange + var filter = new RegionBasedRateLimitAttribute(maxRequests, 60, 10); + var context = ActionFilterHelper.CreateActionExecutingContext("US-token", "ActionName"); + + for (var i = 0; i < maxRequests; i++) + { + // Act + filter.OnActionExecuting(context); + } + + // Assert + Assert.IsNull(context.Result); + } + + [Test] + public void RegionBasedRateLimit_AllowsUKRequest_AfterInterval() + { + // Arrange + var filter = new RegionBasedRateLimitAttribute(5, 60, 1); + var context = ActionFilterHelper.CreateActionExecutingContext("UK-token", "ActionName"); + + // Act + filter.OnActionExecuting(context); + + // Wait for the interval to pass + System.Threading.Thread.Sleep(1001); + + // Act + filter.OnActionExecuting(context); + + // Assert + Assert.IsNull(context.Result); + } + + [Test] + public void RegionBasedRateLimit_BlocksUSRequest_ExceedsLimit() + { + // Arrange + var filter = new RegionBasedRateLimitAttribute(1, 60, 10); + var context = ActionFilterHelper.CreateActionExecutingContext("US-token", "ActionName"); + + // Act + filter.OnActionExecuting(context); + filter.OnActionExecuting(context); // Exceed the limit + + // Assert + Assert.IsInstanceOf(context.Result); + Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); + } + + [Test] + public void RegionBasedRateLimit_BlocksUKRequest_BeforeInterval() + { + // Arrange + var filter = new RegionBasedRateLimitAttribute(5, 60, 2); + var context = ActionFilterHelper.CreateActionExecutingContext("UK-token", "ActionName"); + + // Act + filter.OnActionExecuting(context); + filter.OnActionExecuting(context); // Should be blocked + + // Assert + Assert.IsInstanceOf(context.Result); + Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode); + } +} \ No newline at end of file diff --git a/RateLimiter/Attributes/MinimumTimespanBetweenCallsAttribute.cs b/RateLimiter/Attributes/MinimumTimespanBetweenCallsAttribute.cs new file mode 100644 index 00000000..566a27d1 --- /dev/null +++ b/RateLimiter/Attributes/MinimumTimespanBetweenCallsAttribute.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using RateLimiter.Interfaces; + +namespace RateLimiter.Attributes; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class MinimumTimespanBetweenCallsAttribute : ActionFilterAttribute, IRateLimitingRule +{ + private readonly TimeSpan _timespan; + private readonly Dictionary _lastRequests = new(); + + /// + /// Request-limiting with a certain timespan passed since the last call rule. + /// Defines a certain timespan passed since the last call. + /// + public MinimumTimespanBetweenCallsAttribute(int seconds) + { + _timespan = TimeSpan.FromSeconds(seconds); + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + var token = context.HttpContext.Request.Headers["AccessToken"].ToString(); + var resource = context.ActionDescriptor.DisplayName; + + if (!IsRequestAllowed(token, resource)) + { + context.Result = new ContentResult + { + StatusCode = 429, + Content = "Rate limit exceeded." + }; + } + + base.OnActionExecuting(context); + } + + public bool IsRequestAllowed(string token, string resource) + { + var key = $"{token}_{resource}"; + var now = DateTime.UtcNow; + + if (_lastRequests.TryGetValue(key, out var lastCall) && now - lastCall < _timespan) + { + return false; + } + + _lastRequests[key] = now; + + return true; + } +} \ No newline at end of file diff --git a/RateLimiter/Attributes/RateLimitPerTimespanAttribute.cs b/RateLimiter/Attributes/RateLimitPerTimespanAttribute.cs new file mode 100644 index 00000000..4737ce8d --- /dev/null +++ b/RateLimiter/Attributes/RateLimitPerTimespanAttribute.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using RateLimiter.Interfaces; + +namespace RateLimiter.Attributes; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class RateLimitPerTimespanAttribute : ActionFilterAttribute, IRateLimitingRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _timespan; + private readonly Dictionary> _requests = new(); + + /// + /// Request-limiting with X requests per timespan rule. + /// Defines a maximum count of requests. + /// Defines a timespan. + /// + public RateLimitPerTimespanAttribute(int maxRequests, int seconds) + { + _maxRequests = maxRequests; + _timespan = TimeSpan.FromSeconds(seconds); + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + var token = context.HttpContext.Request.Headers["AccessToken"].ToString(); + var resource = context.ActionDescriptor.DisplayName; + + if (!IsRequestAllowed(token, resource)) + { + context.Result = new ContentResult + { + StatusCode = 429, + Content = "Rate limit exceeded." + }; + } + + base.OnActionExecuting(context); + } + + public bool IsRequestAllowed(string token, string resource) + { + var key = $"{token}_{resource}"; + var now = DateTime.UtcNow; + + if (!_requests.ContainsKey(key)) + { + _requests[key] = new List(); + } + + _requests[key].Add(now); + _requests[key].RemoveAll(timestamp => timestamp < now - _timespan); + + return _requests[key].Count <= _maxRequests; + } +} \ No newline at end of file diff --git a/RateLimiter/Attributes/RegionBasedRateLimitAttribute.cs b/RateLimiter/Attributes/RegionBasedRateLimitAttribute.cs new file mode 100644 index 00000000..4c75f1d8 --- /dev/null +++ b/RateLimiter/Attributes/RegionBasedRateLimitAttribute.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using RateLimiter.Interfaces; + +namespace RateLimiter.Attributes; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class RegionBasedRateLimitAttribute : ActionFilterAttribute, IRateLimitingRule +{ + private readonly IRateLimitingRule _usRule; + private readonly IRateLimitingRule _ukRule; + + /// + /// Request-limiting with a region-based rule. For US-based tokens - X requests per timespan, for EU-based - certain timespan passed since the last call. + /// Defines a maximum count of requests. + /// Defines a timespan. + /// Defines a certain timespan passed since the last call. + /// + public RegionBasedRateLimitAttribute(int usMaxRequests, int usSeconds, int ukSeconds) + { + _usRule = new RateLimitPerTimespanAttribute(usMaxRequests, usSeconds); + _ukRule = new MinimumTimespanBetweenCallsAttribute(ukSeconds); + } + + public bool IsRequestAllowed(string token, string resource) + { + // US-based tokens + if (token.StartsWith("US")) + { + return _usRule.IsRequestAllowed(token, resource); + } + + // UK-based tokens + if (token.StartsWith("UK")) + { + return _ukRule.IsRequestAllowed(token, resource); + } + + return true; + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + var token = context.HttpContext.Request.Headers["AccessToken"].ToString(); + var resource = context.ActionDescriptor.DisplayName; + + if (!IsRequestAllowed(token, resource)) + { + context.Result = new ContentResult + { + StatusCode = 429, + Content = "Rate limit exceeded." + }; + } + + base.OnActionExecuting(context); + } +} \ No newline at end of file diff --git a/RateLimiter/Interfaces/IRateLimitingRule.cs b/RateLimiter/Interfaces/IRateLimitingRule.cs new file mode 100644 index 00000000..6ace7977 --- /dev/null +++ b/RateLimiter/Interfaces/IRateLimitingRule.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Interfaces; + +public interface IRateLimitingRule +{ + bool IsRequestAllowed(string token, string resource); +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..817b8b47 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,7 @@ latest enable + + + \ No newline at end of file