Skip to content

Commit

Permalink
Added action filters for rate limits
Browse files Browse the repository at this point in the history
  • Loading branch information
ArtemLazar committed Jul 3, 2024
1 parent 73f3a7c commit 82b923d
Show file tree
Hide file tree
Showing 10 changed files with 395 additions and 0 deletions.
46 changes: 46 additions & 0 deletions RateLimiter.Tests/Helpers/ActionFilterHelper.cs
Original file line number Diff line number Diff line change
@@ -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<string, StringValues>("AccessToken", token)
}
}
};

var actionContext = new ActionContext
{
HttpContext = httpContext,
RouteData = new RouteData(),
ActionDescriptor = new ActionDescriptor
{
DisplayName = actionName
}
};

var actionExecutingContext = new ActionExecutingContext(
actionContext,
new List<IFilterMetadata>(),
new Dictionary<string, object>(),
new Mock<Controller>().Object
);

return actionExecutingContext;
}
}
42 changes: 42 additions & 0 deletions RateLimiter.Tests/MinimumTimespanBetweenCallsAttributeTests.cs
Original file line number Diff line number Diff line change
@@ -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<ContentResult>(context.Result);
Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode);
}
}
44 changes: 44 additions & 0 deletions RateLimiter.Tests/RateLimitPerTimespanAttributeTests.cs
Original file line number Diff line number Diff line change
@@ -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<ContentResult>(context.Result);
Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode);
}
}
1 change: 1 addition & 0 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
</ItemGroup>
Expand Down
80 changes: 80 additions & 0 deletions RateLimiter.Tests/RegionBasedRateLimitAttributeTests.cs
Original file line number Diff line number Diff line change
@@ -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<ContentResult>(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<ContentResult>(context.Result);
Assert.AreEqual(429, ((ContentResult)context.Result).StatusCode);
}
}
55 changes: 55 additions & 0 deletions RateLimiter/Attributes/MinimumTimespanBetweenCallsAttribute.cs
Original file line number Diff line number Diff line change
@@ -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<string, DateTime> _lastRequests = new();

/// <summary>
/// Request-limiting with a certain timespan passed since the last call rule.
/// <param name="seconds">Defines a certain timespan passed since the last call.</param>
/// </summary>
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;
}
}
59 changes: 59 additions & 0 deletions RateLimiter/Attributes/RateLimitPerTimespanAttribute.cs
Original file line number Diff line number Diff line change
@@ -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<string, List<DateTime>> _requests = new();

/// <summary>
/// Request-limiting with X requests per timespan rule.
/// <param name="maxRequests">Defines a maximum count of requests.</param>
/// <param name="seconds">Defines a timespan.</param>
/// </summary>
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<DateTime>();
}

_requests[key].Add(now);
_requests[key].RemoveAll(timestamp => timestamp < now - _timespan);

return _requests[key].Count <= _maxRequests;
}
}
Loading

0 comments on commit 82b923d

Please sign in to comment.