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

Rate limit #192

Closed
wants to merge 2 commits into from
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
94 changes: 94 additions & 0 deletions RateLimiter.Tests/RateLimitValidatorFactoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Services;

namespace RateLimiter.Tests;

public class RateLimitValidatorFactoryTests
{
private RateLimitValidatorFactory _factory;

[SetUp]
public void Init()
{
var settingsMock = new Mock<IOptions<RateLimitSettings>>();
settingsMock.Setup(x => x.Value).Returns(new RateLimitSettings
{
CommonRule = new RateLimitRule(),
Regions = new[]
{
new RateLimitRule
{
RegionKey = "EU",
Type = RateLimitRuleType.Timespan,
WindowSizeInSeconds = 5
},
new RateLimitRule
{
RegionKey = "EU-failed",
Type = RateLimitRuleType.Timespan,
WindowSizeInSeconds = null
},
new RateLimitRule
{
RegionKey = "US",
Type = RateLimitRuleType.RequestsPerTimespan,
WindowSizeInSeconds = 5,
PermitLimit = 5
},
new RateLimitRule
{
RegionKey = "US-failed",
Type = RateLimitRuleType.RequestsPerTimespan,
WindowSizeInSeconds = null,
PermitLimit = null
},
}
});
_factory = new RateLimitValidatorFactory(settingsMock.Object);
}

[Test]
public void EUCorrect_Create_ShouldReturnTimespanValidator()
{
//Act
var validator = _factory.Create("EU");

//Assert
validator.Should().BeOfType<TimespanRateValidator>();
}

[Test]
public void EUFailed_Create_ShouldThrowArgumentNullException()
{
//Act
var action = () => _factory.Create("EU-failed");

//Assert
action.Should().Throw<ArgumentNullException>();
}

[Test]
public void USCorrect_Create_ShouldReturnWindowLimitValidator()
{
//Act
var validator = _factory.Create("US");

//Assert
validator.Should().BeOfType<WindowLimitRateValidator>();
}

[Test]
public void USFailed_Create_ShouldThrowArgumentNullException()
{
//Act
var action = () => _factory.Create("US-failed");

//Assert
action.Should().Throw<ArgumentNullException>();
}
}
2 changes: 2 additions & 0 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<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
13 changes: 0 additions & 13 deletions RateLimiter.Tests/RateLimiterTest.cs

This file was deleted.

43 changes: 43 additions & 0 deletions RateLimiter.Tests/TimespanRateValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using FluentAssertions;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Services;

namespace RateLimiter.Tests;

public class TimespanRateValidatorTests
{
private TimespanRateValidator _validator;
[SetUp]
public void Init()
{
_validator = new TimespanRateValidator(TimeSpan.FromSeconds(5));
}

[Test]
public void LastVisitLessTimespan_Validate_ShouldReturnTrue()
{
//Arrange
var lastVisit = DateTime.UtcNow.AddSeconds(-10);

//Act
var result = _validator.Validate(new ClientData { LastVisit = lastVisit });

//Assert
result.Result.Should().BeTrue();
}

[Test]
public void LastVisitGreaterTimespan_Validate_ShouldReturnFalse()
{
//Arrange
var lastVisit = DateTime.UtcNow.AddSeconds(-3);

//Act
var result = _validator.Validate(new ClientData { LastVisit = lastVisit });

//Assert
result.Result.Should().BeFalse();
}
}
58 changes: 58 additions & 0 deletions RateLimiter.Tests/WindowLimitRateValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using FluentAssertions;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Services;

namespace RateLimiter.Tests;

public class WindowLimitRateValidatorTests
{
private WindowLimitRateValidator _validator;
[SetUp]
public void Init()
{
_validator = new WindowLimitRateValidator(TimeSpan.FromSeconds(5), 5);
}

[Test]
public void LastVisitLessTimespanAndVisitCountsLessLimit_Validate_ShouldReturnTrue()
{
//Arrange
var lastVisit = DateTime.UtcNow.AddSeconds(-10);

//Act
var result = _validator.Validate(new ClientData { LastVisit = lastVisit, VisitCounts = 4});

//Assert
result.Result.Should().BeTrue();
}

[Test]
public void LastVisitGreaterTimespanAndVisitCountsLessLimit_Validate_ShouldReturnTrue()
{
//Arrange
var lastVisit = DateTime.UtcNow.AddSeconds(-3);

//Act
var result = _validator.Validate(new ClientData { LastVisit = lastVisit, VisitCounts = 4});

//Assert
result.Result.Should().BeTrue();
result.VisitCounts.Should().Be(5);
}

[Test]
public void LastVisitGreaterTimespanAndVisitCountsEqualsLimit_Validate_ShouldReturnTrue()
{
//Arrange
var lastVisit = DateTime.UtcNow.AddSeconds(-3);

//Act
var result = _validator.Validate(new ClientData { LastVisit = lastVisit, VisitCounts = 5});

//Assert
result.Result.Should().BeFalse();
result.VisitCounts.Should().Be(5);
}
}
25 changes: 25 additions & 0 deletions RateLimiter/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using RateLimiter.Models;
using RateLimiter.Services;

namespace RateLimiter.Extensions;

[ExcludeFromCodeCoverage]
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddRateLimit(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<RateLimitSettings>(configuration.GetSection(RateLimitSettings.Section));
services.AddTransient<IRateLimitValidatorFactory, RateLimitValidatorFactory>();

return services;
}

public static IApplicationBuilder UseRateLimit(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RateLimitingMiddleware>();
}
}
15 changes: 15 additions & 0 deletions RateLimiter/Models/ClientData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;

namespace RateLimiter.Models;

public struct ClientData
{
public DateTime LastVisit { get; set; }
public int VisitCounts { get; set; }

public static ClientData Empty => new()
{
LastVisit = DateTime.UtcNow,
VisitCounts = 0
};
}
9 changes: 9 additions & 0 deletions RateLimiter/Models/RateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace RateLimiter.Models;

public class RateLimitRule
{
public string? RegionKey { get; set; }
public RateLimitRuleType Type { get; set; }
public int? PermitLimit { get; set; }
public int? WindowSizeInSeconds { get; set; }
}
7 changes: 7 additions & 0 deletions RateLimiter/Models/RateLimitRuleType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RateLimiter.Models;

public enum RateLimitRuleType
{
RequestsPerTimespan,
Timespan
}
10 changes: 10 additions & 0 deletions RateLimiter/Models/RateLimitSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;

namespace RateLimiter.Models;

public class RateLimitSettings
{
public static string Section => nameof(RateLimitSettings);
public RateLimitRule CommonRule { get; set; } = null!;
public RateLimitRule[] Regions { get; set; } = null!;
}
5 changes: 5 additions & 0 deletions RateLimiter/Models/RateLimitValidationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace RateLimiter.Models;

public record RateLimitValidationResult(
bool Result,
int? VisitCounts = null);
6 changes: 6 additions & 0 deletions RateLimiter/RateLimiter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
</Project>
52 changes: 52 additions & 0 deletions RateLimiter/RateLimitingMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Collections.Concurrent;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using RateLimiter.Models;
using RateLimiter.Services;

namespace RateLimiter;

public class RateLimitingMiddleware
{
private static readonly ConcurrentDictionary<string, ClientData> Clients = new();
private static readonly object SyncLock = new();
private readonly RequestDelegate _next;
public RateLimitingMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task Invoke(HttpContext context, IRateLimitValidatorFactory rateLimitValidatorFactory)
{
var clientId = context.Connection.RemoteIpAddress.ToString();
var regionKey = GetRegionKey(context.User);
lock (SyncLock)
{
var clientData = Clients.GetOrAdd(clientId, ClientData.Empty);
var validator = rateLimitValidatorFactory.Create(regionKey);
var result = validator.Validate(clientData);
if (!result.Result)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
return;
}

if (result.VisitCounts.HasValue)
{
clientData.VisitCounts = result.VisitCounts.Value;
}

Clients[clientId] = clientData;
}

await _next(context);
}

private static string? GetRegionKey(ClaimsPrincipal user)
{
var regionKeyClaim = user.Claims.FirstOrDefault(c => c.Type == "RegionKey");
return regionKeyClaim?.Value;
}
}
8 changes: 8 additions & 0 deletions RateLimiter/Services/IRateLimitValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using RateLimiter.Models;

namespace RateLimiter.Services;

public interface IRateLimitValidator
{
RateLimitValidationResult Validate(ClientData data);
}
Loading
Loading