Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added action filters for rate limits #197

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading