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

Randall Sexton #287

Closed
wants to merge 31 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6a97f7a
initial commit with skeleton for limiting, configs, middleware, client
jrandallsexton Feb 8, 2025
2304a73
work on configurable and extensible rules
jrandallsexton Feb 8, 2025
4d34a93
more skeleton work. begin implementation now
jrandallsexton Feb 9, 2025
09946e2
configuration-based working; needs a lot of cleanup and extensibility
jrandallsexton Feb 10, 2025
eae21e8
extensibility implemented
jrandallsexton Feb 10, 2025
04cbe05
docs, test, housekeeping, and expansion
jrandallsexton Feb 10, 2025
d277c66
Merge branch 'feature/initialFork'
jrandallsexton Feb 10, 2025
6e4beca
cleanup and PR prep
jrandallsexton Feb 11, 2025
5e9dbb6
cleanup
jrandallsexton Feb 12, 2025
4dad435
narrative
jrandallsexton Feb 12, 2025
5076bf9
submission notes
jrandallsexton Feb 12, 2025
cafb3a9
submission notes
jrandallsexton Feb 12, 2025
4297e39
formatting
jrandallsexton Feb 12, 2025
0d3f26f
table formatting issue
jrandallsexton Feb 12, 2025
3ec9367
update mermaid
jrandallsexton Feb 12, 2025
9009f44
more submission notes/houskeeping
jrandallsexton Feb 12, 2025
1348df4
submission notes
jrandallsexton Feb 12, 2025
fabb4dd
submission notes
jrandallsexton Feb 12, 2025
e23b0ae
remove minimal api test client
jrandallsexton Feb 12, 2025
5c22c02
remove some files
jrandallsexton Feb 12, 2025
ce119e9
addendum added to submission.md
jrandallsexton Feb 13, 2025
c9cdf52
epilogue updated
jrandallsexton Feb 13, 2025
5847c61
rework of first implementation
jrandallsexton Feb 15, 2025
2d76f54
prep for distributed caching
jrandallsexton Feb 15, 2025
07711b7
configuration validation and test
jrandallsexton Feb 16, 2025
8a2f3ad
added integration test for threading checks within rateLimiter
jrandallsexton Feb 18, 2025
206d22f
add configuration validation
jrandallsexton Feb 18, 2025
c54927a
housekeeping & fixed test
jrandallsexton Feb 19, 2025
0ce58f2
ignore nullable due to config checks
jrandallsexton Feb 19, 2025
2cbd28e
Merge branch 'feature/multi-algo'
jrandallsexton Feb 19, 2025
7d2f368
just moved some files to diff ns
jrandallsexton Feb 19, 2025
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
49 changes: 49 additions & 0 deletions RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Mvc;

using RateLimiter.Config;

namespace RateLimiter.Tests.Api.Controllers
{
//[RateLimitedResource(RuleName = "RequestsPerTimespan-Default")]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

private readonly ILogger<WeatherForecastController> _logger;

public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}

[RateLimitedResource(RuleName = "GeoTokenRule")]
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}

[HttpGet("{maxRandom}")]
public IEnumerable<WeatherForecast> GetAlt(int maxRandom)
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using RateLimiter.Abstractions;
using RateLimiter.Discriminators;

using static RateLimiter.Config.RateLimiterConfiguration;

namespace RateLimiter.Tests.Api.Middleware.RateLimiting;

public class GeoTokenDiscriminator : IRateLimitDiscriminator
{
public DiscriminatorConfiguration Configuration { get; set; }

/// <summary>
/// This functionality could have been obtained using <see cref="RequestHeaderDiscriminator"/>, but showing extensibility
/// </summary>
/// <param name="context"></param>
/// <param name="rateLimitRule"></param>
/// <returns></returns>
public DiscriminatorEvaluationResult Evaluate(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("x-crexi-token", out var value))
{
return new DiscriminatorEvaluationResult(Configuration.Name)
{
IsMatch = false
};
}

return value.ToString().StartsWith("US") ?
new DiscriminatorEvaluationResult(Configuration.Name)
{
IsMatch = true,
MatchValue = value.ToString(),
AlgorithmName = Configuration.AlgorithmNames[0]
} :
new DiscriminatorEvaluationResult(Configuration.Name)
{
IsMatch = true,
MatchValue = value.ToString(),
AlgorithmName = Configuration.AlgorithmNames[1]
};
}
}
30 changes: 30 additions & 0 deletions RateLimiter.Tests.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using RateLimiter.Config;
using RateLimiter.DependencyInjection;
using RateLimiter.Tests.Api.Middleware.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddSwaggerGen();

builder.Services.AddRateLimiting()
.WithCustomDiscriminator<GeoTokenDiscriminator>()
.WithConfiguration<RateLimiterConfiguration>(builder.Configuration.GetSection("RateLimiter"));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseAuthorization();

app.MapControllers();

app.UseRateLimiting();

app.Run();
15 changes: 15 additions & 0 deletions RateLimiter.Tests.Api/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": false,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5252"
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}
21 changes: 21 additions & 0 deletions RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="RateLimiter.Tests.Integration"></InternalsVisibleTo>
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj.user
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>https</ActiveDebugProfile>
</PropertyGroup>
</Project>
6 changes: 6 additions & 0 deletions RateLimiter.Tests.Api/RateLimiter.Tests.Api.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@RateLimiter.Tests.Api_HostAddress = http://localhost:5252

GET {{RateLimiter.Tests.Api_HostAddress}}/weatherforecast/
Accept: application/json

###
13 changes: 13 additions & 0 deletions RateLimiter.Tests.Api/WeatherForecast.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace RateLimiter.Tests.Api
{
public class WeatherForecast
{
public DateOnly Date { get; set; }

public int TemperatureC { get; set; }

public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);

public string? Summary { get; set; }
}
}
8 changes: 8 additions & 0 deletions RateLimiter.Tests.Api/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
44 changes: 44 additions & 0 deletions RateLimiter.Tests.Api/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"RateLimiter": {
"Algorithms": [
{
"Name": "TSElapsed0",
"Type": "TimespanElapsed",
"Parameters": {
"MinIntervalMS": 3000
}
},
{
"Name": "ReqPerTspan0",
"Type": "FixedWindow",
"Parameters": {
"MaxRequests": 2,
"WindowDurationMS": 3000
}
}
],
"Discriminators": [
{
"Name": "GeoTokenDisc",
"Type": "Custom",
"CustomDiscriminatorType": "GeoTokenDiscriminator",
"DiscriminatorKey": null,
"DiscriminatorMatch": null,
"AlgorithmNames": [ "ReqPerTspan0", "TSElapsed0" ]
}
],
"Rules": [
{
"Name": "GeoTokenRule",
"Discriminators": [ "GeoTokenDisc" ]
}
]
}
}
21 changes: 21 additions & 0 deletions RateLimiter.Tests.Integration/RateLimiter.Tests.Integration.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AutoFixture" Version="4.18.1" />
<PackageReference Include="FluentAssertions" Version="7.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\RateLimiter.Tests.Api\RateLimiter.Tests.Api.csproj" />
</ItemGroup>

</Project>
88 changes: 88 additions & 0 deletions RateLimiter.Tests.Integration/WeatherForecastControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using FluentAssertions;

using Microsoft.AspNetCore.Mvc.Testing;

using System.Net;

using Xunit;
using Xunit.Abstractions;

namespace RateLimiter.Tests.Integration
{
public class WeatherForecastControllerTests(ITestOutputHelper output)
{
[Fact]
public async Task GetAsync_ReturnsOk()
{
var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
var response = await client.GetAsync("WeatherForecast");

response.StatusCode.Should().Be(HttpStatusCode.OK);
}

/// <summary>
/// Represents many unique clients (US and EU) making many calls to our API
/// </summary>
/// <returns></returns>
[Fact]
public async Task GetAsync_WhenAllTokensAreUnique_RequestsAreNotRateLimited()
{
// arrange
var factory = new WebApplicationFactory<Program>();

var tasks = new List<Task<HttpResponseMessage>>();

for (var i = 0; i < 10_000; i++)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-crexi-token",
i % 2 == 0 ? $"US-{Guid.NewGuid()}" : $"EU-{Guid.NewGuid()}");

tasks.Add(client.GetAsync("WeatherForecast"));
}

// act
await Task.WhenAll(tasks);

// assert
var limited = tasks.Any(t => !t.Result.IsSuccessStatusCode);
limited.Should().BeFalse(because: "all clients have unique tokens");
}

/// <summary>
/// Represents two clients (one US and one EU) making many calls to our API
/// </summary>
/// <returns></returns>
[Fact]
public async Task GetAsync_WhenTokensAreNotUnique_RequestsAreRateLimited()
{
// arrange
var factory = new WebApplicationFactory<Program>();

var tasks = new List<Task<HttpResponseMessage>>();

var usToken = $"US-{Guid.NewGuid()}";
var euToken = $"EU-{Guid.NewGuid()}";

for (var i = 0; i < 10_000; i++)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-crexi-token",
i % 2 == 0 ? usToken : euToken);
tasks.Add(client.GetAsync("WeatherForecast"));
}

// act
await Task.WhenAll(tasks);

// assert
var allowed = tasks.Count(t => t.Result.IsSuccessStatusCode);
var limited = tasks.Count(t => !t.Result.IsSuccessStatusCode);

output.WriteLine($"Allowed: {allowed}\tLimited: {limited}");
var percent = ((double)limited / tasks.Count)*100;
percent.Should().BeApproximately(99, 2.0, because: "two clients are banging on the door");
}
}
}
Loading