From 6a97f7a0652d0a76c3f11463ecb02ae4678512e6 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Sat, 8 Feb 2025 09:47:36 -0500 Subject: [PATCH 01/29] initial commit with skeleton for limiting, configs, middleware, client --- RateLimiter.Tests.Api.Minimal/Program.cs | 44 ++++++++++++++ .../Properties/launchSettings.json | 23 ++++++++ .../RateLimiter.Tests.Api.Minimal.csproj | 17 ++++++ .../RateLimiter.Tests.Api.Minimal.csproj.user | 6 ++ .../RateLimiter.Tests.Api.Minimal.http | 6 ++ .../appsettings.Development.json | 8 +++ .../appsettings.json | 9 +++ .../Controllers/WeatherForecastController.cs | 36 ++++++++++++ RateLimiter.Tests.Api/Program.cs | 27 +++++++++ .../Properties/launchSettings.json | 15 +++++ .../RateLimiter.Tests.Api.csproj | 18 ++++++ .../RateLimiter.Tests.Api.csproj.user | 6 ++ .../RateLimiter.Tests.Api.http | 6 ++ RateLimiter.Tests.Api/WeatherForecast.cs | 13 +++++ .../appsettings.Development.json | 8 +++ RateLimiter.Tests.Api/appsettings.json | 9 +++ RateLimiter.Tests/RateLimiter.Tests.csproj | 7 ++- RateLimiter.Tests/RateLimiterTest.cs | 14 +++-- .../Rules/FixedWindowRuleTests.cs | 38 ++++++++++++ RateLimiter.sln | 28 ++++++++- RateLimiter.sln.DotSettings.user | 4 ++ RateLimiter/Abstractions/IDateTimeProvider.cs | 8 +++ .../Abstractions/IProvideRateLimitRules.cs | 8 +++ .../Abstractions/IRateLimitRequests.cs | 10 ++++ RateLimiter/Abstractions/IRateLimitRule.cs | 6 ++ RateLimiter/Common/DateTimeProvider.cs | 13 +++++ .../Config/RateLimitRuleConfiguration.cs | 13 +++++ RateLimiter/Config/RateLimited.cs | 26 +++++++++ .../Config/RateLimiterConfiguration.cs | 6 ++ RateLimiter/Config/RateLimitingAlgorithm.cs | 9 +++ .../RateLimiterRegister.cs | 29 +++++++++ .../Middleware/RateLimiterMiddleware.cs | 55 ++++++++++++++++++ RateLimiter/Properties/launchSettings.json | 12 ++++ RateLimiter/RateLimiter.cs | 26 +++++++++ RateLimiter/RateLimiter.csproj | 14 ++++- RateLimiter/RateLimiterRulesFactory.cs | 13 +++++ RateLimiter/Rules/FixedWindowRule.cs | 37 ++++++++++++ RateLimiter/Rules/LeakyBucketRule.cs | 13 +++++ RateLimiter/Rules/SlidingWindowRule.cs | 13 +++++ RateLimiter/Rules/TokenBucketRule.cs | 13 +++++ dev-notes.md | 29 +++++++++ overview.mermaid | 14 +++++ overview.png | Bin 0 -> 16228 bytes 43 files changed, 695 insertions(+), 14 deletions(-) create mode 100644 RateLimiter.Tests.Api.Minimal/Program.cs create mode 100644 RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json create mode 100644 RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj create mode 100644 RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user create mode 100644 RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http create mode 100644 RateLimiter.Tests.Api.Minimal/appsettings.Development.json create mode 100644 RateLimiter.Tests.Api.Minimal/appsettings.json create mode 100644 RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs create mode 100644 RateLimiter.Tests.Api/Program.cs create mode 100644 RateLimiter.Tests.Api/Properties/launchSettings.json create mode 100644 RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj create mode 100644 RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj.user create mode 100644 RateLimiter.Tests.Api/RateLimiter.Tests.Api.http create mode 100644 RateLimiter.Tests.Api/WeatherForecast.cs create mode 100644 RateLimiter.Tests.Api/appsettings.Development.json create mode 100644 RateLimiter.Tests.Api/appsettings.json create mode 100644 RateLimiter.Tests/Rules/FixedWindowRuleTests.cs create mode 100644 RateLimiter.sln.DotSettings.user create mode 100644 RateLimiter/Abstractions/IDateTimeProvider.cs create mode 100644 RateLimiter/Abstractions/IProvideRateLimitRules.cs create mode 100644 RateLimiter/Abstractions/IRateLimitRequests.cs create mode 100644 RateLimiter/Abstractions/IRateLimitRule.cs create mode 100644 RateLimiter/Common/DateTimeProvider.cs create mode 100644 RateLimiter/Config/RateLimitRuleConfiguration.cs create mode 100644 RateLimiter/Config/RateLimited.cs create mode 100644 RateLimiter/Config/RateLimiterConfiguration.cs create mode 100644 RateLimiter/Config/RateLimitingAlgorithm.cs create mode 100644 RateLimiter/DependencyInjection/RateLimiterRegister.cs create mode 100644 RateLimiter/Middleware/RateLimiterMiddleware.cs create mode 100644 RateLimiter/Properties/launchSettings.json create mode 100644 RateLimiter/RateLimiter.cs create mode 100644 RateLimiter/RateLimiterRulesFactory.cs create mode 100644 RateLimiter/Rules/FixedWindowRule.cs create mode 100644 RateLimiter/Rules/LeakyBucketRule.cs create mode 100644 RateLimiter/Rules/SlidingWindowRule.cs create mode 100644 RateLimiter/Rules/TokenBucketRule.cs create mode 100644 dev-notes.md create mode 100644 overview.mermaid create mode 100644 overview.png diff --git a/RateLimiter.Tests.Api.Minimal/Program.cs b/RateLimiter.Tests.Api.Minimal/Program.cs new file mode 100644 index 00000000..b4e35f3f --- /dev/null +++ b/RateLimiter.Tests.Api.Minimal/Program.cs @@ -0,0 +1,44 @@ +using RateLimiter.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithRateLimiting() +.WithName("GetWeatherForecast"); + +app.Run(); + +internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json b/RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json new file mode 100644 index 00000000..2a334b3d --- /dev/null +++ b/RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5256", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7191;http://localhost:5256", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj new file mode 100644 index 00000000..e0404f43 --- /dev/null +++ b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user new file mode 100644 index 00000000..9ff5820a --- /dev/null +++ b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http new file mode 100644 index 00000000..5558cc7d --- /dev/null +++ b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http @@ -0,0 +1,6 @@ +@RateLimiter.Tests.Api.Minimal_HostAddress = http://localhost:5256 + +GET {{RateLimiter.Tests.Api.Minimal_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/RateLimiter.Tests.Api.Minimal/appsettings.Development.json b/RateLimiter.Tests.Api.Minimal/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/RateLimiter.Tests.Api.Minimal/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/RateLimiter.Tests.Api.Minimal/appsettings.json b/RateLimiter.Tests.Api.Minimal/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/RateLimiter.Tests.Api.Minimal/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs new file mode 100644 index 00000000..51b88861 --- /dev/null +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using RateLimiter.Config; + +namespace RateLimiter.Tests.Api.Controllers +{ + [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 _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [RateLimited(LimiterType = LimiterType.RequestsPerTimespan, Config = "nameOfTheConfigEntry", Discriminator = LimiterDiscriminator.IpAddress)] + [RateLimited(LimiterType = LimiterType.RequestsPerTimespan, Config = "nameOfTheConfigEntry", Discriminator = LimiterDiscriminator.GeoLocation)] + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable 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(); + } + } +} diff --git a/RateLimiter.Tests.Api/Program.cs b/RateLimiter.Tests.Api/Program.cs new file mode 100644 index 00000000..67e399be --- /dev/null +++ b/RateLimiter.Tests.Api/Program.cs @@ -0,0 +1,27 @@ +using RateLimiter.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); +builder.Services.AddSwaggerGen(); +builder.Services.AddRateLimiting(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.UseRateLimiting(); + +app.Run(); diff --git a/RateLimiter.Tests.Api/Properties/launchSettings.json b/RateLimiter.Tests.Api/Properties/launchSettings.json new file mode 100644 index 00000000..8ccb1f87 --- /dev/null +++ b/RateLimiter.Tests.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5252" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj new file mode 100644 index 00000000..9331526c --- /dev/null +++ b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj.user b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj.user new file mode 100644 index 00000000..9ff5820a --- /dev/null +++ b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/RateLimiter.Tests.Api/RateLimiter.Tests.Api.http b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.http new file mode 100644 index 00000000..6dd4306e --- /dev/null +++ b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.http @@ -0,0 +1,6 @@ +@RateLimiter.Tests.Api_HostAddress = http://localhost:5252 + +GET {{RateLimiter.Tests.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/RateLimiter.Tests.Api/WeatherForecast.cs b/RateLimiter.Tests.Api/WeatherForecast.cs new file mode 100644 index 00000000..690f2b95 --- /dev/null +++ b/RateLimiter.Tests.Api/WeatherForecast.cs @@ -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; } + } +} diff --git a/RateLimiter.Tests.Api/appsettings.Development.json b/RateLimiter.Tests.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/RateLimiter.Tests.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/RateLimiter.Tests.Api/appsettings.json b/RateLimiter.Tests.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/RateLimiter.Tests.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..7caa7239 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -1,6 +1,6 @@  - net6.0 + net9.0 latest enable @@ -8,8 +8,9 @@ + - - + + \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..5c8b0f71 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,15 @@ -using NUnit.Framework; + +using FluentAssertions; + +using Xunit; namespace RateLimiter.Tests; -[TestFixture] public class RateLimiterTest { - [Test] + [Fact] public void Example() - { - Assert.That(true, Is.True); - } + { + true.Should().BeFalse(); + } } \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs new file mode 100644 index 00000000..2d12fc0f --- /dev/null +++ b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; + +using RateLimiter.Rules; + +using System; +using System.Threading; +using Xunit; + +namespace RateLimiter.Tests.Rules +{ + public class FixedWindowRuleTests + { + [Fact] + public void WhenFoo_DoesBar() + { + var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(3)); + + // First 3 requests allowed + rule.IsAllowed("client1").Should().BeTrue(); + rule.IsAllowed("client1").Should().BeTrue(); + rule.IsAllowed("client1").Should().BeTrue(); + + // Fourth request blocked + rule.IsAllowed("client1").Should().BeFalse(); + + // wait 3 seconds ... + Thread.Sleep(3000); + + // First 3 requests allowed + rule.IsAllowed("client1").Should().BeTrue(); + rule.IsAllowed("client1").Should().BeTrue(); + rule.IsAllowed("client1").Should().BeTrue(); + + // Fourth request blocked + rule.IsAllowed("client1").Should().BeFalse(); + } + } +} diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..4d3d5a34 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.15 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35707.178 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" EndProject @@ -9,9 +9,19 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLi EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}" ProjectSection(SolutionItems) = preProject + dev-notes.md = dev-notes.md + overview.mermaid = overview.mermaid README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EFA099B0-7DF4-40D2-8CAA-92730F7E25BF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests.Api", "RateLimiter.Tests.Api\RateLimiter.Tests.Api.csproj", "{74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests.Api.Minimal", "RateLimiter.Tests.Api.Minimal\RateLimiter.Tests.Api.Minimal.csproj", "{695C99D4-BA16-47F6-B84E-4ADE78580803}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,10 +36,24 @@ Global {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.Build.0 = Release|Any CPU + {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Release|Any CPU.Build.0 = Release|Any CPU + {695C99D4-BA16-47F6-B84E-4ADE78580803}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {695C99D4-BA16-47F6-B84E-4ADE78580803}.Debug|Any CPU.Build.0 = Debug|Any CPU + {695C99D4-BA16-47F6-B84E-4ADE78580803}.Release|Any CPU.ActiveCfg = Release|Any CPU + {695C99D4-BA16-47F6-B84E-4ADE78580803}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F} = {EFA099B0-7DF4-40D2-8CAA-92730F7E25BF} + {C4F9249B-010E-46BE-94B8-DD20D82F1E60} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} + {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} + {695C99D4-BA16-47F6-B84E-4ADE78580803} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {67D05CB6-8603-4C96-97E5-C6CEFBEC6134} EndGlobalSection diff --git a/RateLimiter.sln.DotSettings.user b/RateLimiter.sln.DotSettings.user new file mode 100644 index 00000000..5341da72 --- /dev/null +++ b/RateLimiter.sln.DotSettings.user @@ -0,0 +1,4 @@ + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> \ No newline at end of file diff --git a/RateLimiter/Abstractions/IDateTimeProvider.cs b/RateLimiter/Abstractions/IDateTimeProvider.cs new file mode 100644 index 00000000..15ad9e8c --- /dev/null +++ b/RateLimiter/Abstractions/IDateTimeProvider.cs @@ -0,0 +1,8 @@ +using System; + +namespace RateLimiter.Abstractions; + +public interface IDateTimeProvider +{ + DateTime UtcNow(); +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IProvideRateLimitRules.cs b/RateLimiter/Abstractions/IProvideRateLimitRules.cs new file mode 100644 index 00000000..2709ae18 --- /dev/null +++ b/RateLimiter/Abstractions/IProvideRateLimitRules.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace RateLimiter.Abstractions; + +public interface IProvideRateLimitRules +{ + IEnumerable GetRules(); +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRequests.cs b/RateLimiter/Abstractions/IRateLimitRequests.cs new file mode 100644 index 00000000..1621dfad --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimitRequests.cs @@ -0,0 +1,10 @@ +using RateLimiter.Config; + +using System.Collections.Generic; + +namespace RateLimiter; + +public interface IRateLimitRequests +{ + (bool, string) IsRequestAllowed(IEnumerable rules); +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRule.cs b/RateLimiter/Abstractions/IRateLimitRule.cs new file mode 100644 index 00000000..3f6a04ca --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimitRule.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Abstractions; + +public interface IRateLimitRule +{ + bool IsAllowed(string discriminator); +} \ No newline at end of file diff --git a/RateLimiter/Common/DateTimeProvider.cs b/RateLimiter/Common/DateTimeProvider.cs new file mode 100644 index 00000000..5c6179ba --- /dev/null +++ b/RateLimiter/Common/DateTimeProvider.cs @@ -0,0 +1,13 @@ +using RateLimiter.Abstractions; + +using System; + +namespace RateLimiter.Common; + +public class DateTimeProvider : IDateTimeProvider +{ + public DateTime UtcNow() + { + return DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/RateLimiter/Config/RateLimitRuleConfiguration.cs b/RateLimiter/Config/RateLimitRuleConfiguration.cs new file mode 100644 index 00000000..e354e3be --- /dev/null +++ b/RateLimiter/Config/RateLimitRuleConfiguration.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Config; + +public class RateLimitRuleConfiguration +{ + // TODO: Spec out what needs to be configured for different rule types + // TODO: Determine different rule types and what they look like +} \ No newline at end of file diff --git a/RateLimiter/Config/RateLimited.cs b/RateLimiter/Config/RateLimited.cs new file mode 100644 index 00000000..a8332e30 --- /dev/null +++ b/RateLimiter/Config/RateLimited.cs @@ -0,0 +1,26 @@ +using System; + +namespace RateLimiter.Config; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class RateLimited : Attribute +{ + public LimiterType LimiterType { get; set; } + + public string Config { get; set; } + + public LimiterDiscriminator Discriminator { get; set; } +} + +public enum LimiterType +{ + RequestsPerTimespan, + TimespanElapsed +} + +public enum LimiterDiscriminator +{ + IpAddress, + GeoLocation, + IpSubNet +} \ No newline at end of file diff --git a/RateLimiter/Config/RateLimiterConfiguration.cs b/RateLimiter/Config/RateLimiterConfiguration.cs new file mode 100644 index 00000000..9be41ddd --- /dev/null +++ b/RateLimiter/Config/RateLimiterConfiguration.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Config; + +public class RateLimiterConfiguration +{ + public RateLimitingAlgorithm Algorithm { get; set; } +} \ No newline at end of file diff --git a/RateLimiter/Config/RateLimitingAlgorithm.cs b/RateLimiter/Config/RateLimitingAlgorithm.cs new file mode 100644 index 00000000..ff198819 --- /dev/null +++ b/RateLimiter/Config/RateLimitingAlgorithm.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Config; + +public enum RateLimitingAlgorithm +{ + TokenBucket, + LeakyBucket, + FixedWindow, + SlidingWindow +} \ No newline at end of file diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs new file mode 100644 index 00000000..afdd0415 --- /dev/null +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using RateLimiter.Abstractions; +using RateLimiter.Middleware; + +namespace RateLimiter.DependencyInjection; + +public static class RateLimiterRegister +{ + public static IServiceCollection AddRateLimiting(this IServiceCollection services) + { + // TODO: Need the configuration + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static WebApplication UseRateLimiting(this WebApplication app) + { + app.UseMiddleware(); + return app; + } + + public static RouteHandlerBuilder WithRateLimiting(this RouteHandlerBuilder builder) + { + // TODO: Implement + return builder; + } +} \ No newline at end of file diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs new file mode 100644 index 00000000..a55ffce7 --- /dev/null +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -0,0 +1,55 @@ +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; + +using RateLimiter.Config; + +using System.Threading.Tasks; + +namespace RateLimiter.Middleware; + +public class RateLimiterMiddleware +{ + private readonly ILogger _logger; + private readonly RequestDelegate _next; + private readonly IRateLimitRequests _rateLimiter; + + public RateLimiterMiddleware( + ILogger logger, + RequestDelegate next, + IRateLimitRequests rateLimiter) + { + _logger = logger; + _next = next; + _rateLimiter = rateLimiter; + } + + public async Task Invoke(HttpContext context) + { + var endpoint = context.Features.Get()?.Endpoint; + + var attributes = endpoint?.Metadata.GetOrderedMetadata(); + + if (attributes is not null && attributes.Any()) + { + // TODO: Do not default to this discriminator + var token = context.Request.Query["clientToken"]; + + var (isAllowed, message) = _rateLimiter.IsRequestAllowed(attributes); + + if (!isAllowed) + { + // if invalid, or rate-limited: + // log + // return 429 + _logger.LogWarning("Client was rate limited with {@Token}", token); + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsync(message); + return; + } + } + + await _next(context); + } +} \ No newline at end of file diff --git a/RateLimiter/Properties/launchSettings.json b/RateLimiter/Properties/launchSettings.json new file mode 100644 index 00000000..8d5dfff6 --- /dev/null +++ b/RateLimiter/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "RateLimiter": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:51985;http://localhost:51986" + } + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..5ab49ac3 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,26 @@ +using RateLimiter.Abstractions; +using RateLimiter.Config; + +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter; + +public class RateLimiter : IRateLimitRequests +{ + private readonly IEnumerable _rules; + + public RateLimiter( + IProvideRateLimitRules rulesFactory) + { + _rules = rulesFactory.GetRules(); + } + + public (bool, string) IsRequestAllowed(IEnumerable rules) + { + var passed = _rules.All(rule => rule.IsAllowed("foo")); + + return passed ? (passed, string.Empty) : + (passed, "some message about banging on our door too much"); + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..f82ab61b 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -1,7 +1,15 @@  - net6.0 - latest - enable + net9.0 + latest + enable + false + + + + + + + \ No newline at end of file diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs new file mode 100644 index 00000000..60ecdf74 --- /dev/null +++ b/RateLimiter/RateLimiterRulesFactory.cs @@ -0,0 +1,13 @@ +using RateLimiter.Abstractions; + +using System.Collections.Generic; + +namespace RateLimiter; + +public class RateLimiterRulesFactory : IProvideRateLimitRules +{ + public IEnumerable GetRules() + { + return new List(); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..f0633148 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -0,0 +1,37 @@ +using RateLimiter.Abstractions; + +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules; + +public class FixedWindowRule : IRateLimitRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _windowDuration; + private readonly ConcurrentDictionary _clientWindows; + + public FixedWindowRule(int maxRequests, TimeSpan windowDuration) + { + _maxRequests = maxRequests; + _windowDuration = windowDuration; + _clientWindows = new ConcurrentDictionary(); + } + + public bool IsAllowed(string discriminator) + { + var now = DateTime.UtcNow; + + // Atomically update or create a window for the client + var window = _clientWindows.AddOrUpdate( + discriminator, + (1, now), // New client: start window with 1 request + (_, existing) => now - existing.WindowStart >= _windowDuration ? + // Window expired: reset count and start new window + (1, now) : + // Still in window: increment + (existing.Count + 1, existing.WindowStart)); + + return window.Count <= _maxRequests; + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/LeakyBucketRule.cs b/RateLimiter/Rules/LeakyBucketRule.cs new file mode 100644 index 00000000..4b8e6a3b --- /dev/null +++ b/RateLimiter/Rules/LeakyBucketRule.cs @@ -0,0 +1,13 @@ +using RateLimiter.Abstractions; + +using System; + +namespace RateLimiter.Rules; + +public class LeakyBucketRule : IRateLimitRule +{ + public bool IsAllowed(string discriminator) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/SlidingWindowRule.cs new file mode 100644 index 00000000..614318e4 --- /dev/null +++ b/RateLimiter/Rules/SlidingWindowRule.cs @@ -0,0 +1,13 @@ +using RateLimiter.Abstractions; + +using System; + +namespace RateLimiter.Rules; + +public class SlidingWindowRule : IRateLimitRule +{ + public bool IsAllowed(string discriminator) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/TokenBucketRule.cs b/RateLimiter/Rules/TokenBucketRule.cs new file mode 100644 index 00000000..f1f389b0 --- /dev/null +++ b/RateLimiter/Rules/TokenBucketRule.cs @@ -0,0 +1,13 @@ +using RateLimiter.Abstractions; + +using System; + +namespace RateLimiter.Rules; + +public class TokenBucketRule : IRateLimitRule +{ + public bool IsAllowed(string discriminator) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/dev-notes.md b/dev-notes.md new file mode 100644 index 00000000..6c175404 --- /dev/null +++ b/dev-notes.md @@ -0,0 +1,29 @@ +Top-down approach? If this rate limiter were a nuget pkg that I was going to utilize, how would I imagine I would configure/use it? + +What would an endpoint look like? + +``` +public class ResourceA +{ + [RateLimit("clientToken", Strategy.RequestsPerTimespan)] + [HttpGet()] + public async Task GetResources(string id) { + // ... implementation + } + + [RateLimit("clientToken", Strategy.GeoBased)] + [RateLimit("clientToken", Strategy.RequestsPerTimespan)] + [HttpGet("{id}")] + public async Task GetResourceById(string id) { + // ... implementation + } +} +``` + +hmm. what about for minimal apis? the library should support both implementation. + +``` +app.MapGet("/get", context -> context.Response.WriteAsync("get")).RateLimit(clientToke, Strategy.RequestsPerTimespan); +``` + +I think something like that would be acceptable. diff --git a/overview.mermaid b/overview.mermaid new file mode 100644 index 00000000..c38f995f --- /dev/null +++ b/overview.mermaid @@ -0,0 +1,14 @@ +flowchart TB + A[Client] -->|HTTP/S| API + subgraph API + + subgraph Middleware + subgraph RateLimitingMiddleware + subgraph RateLimiter + RateLimiterRulesFactory + RateLimiterConfiguation + RateLimiterRuleConfiguration + end + end + end + end \ No newline at end of file diff --git a/overview.png b/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..f40b5ad5179f37578658583fa50876a0dbf04480 GIT binary patch literal 16228 zcmeHudsI``ws))tJ;hplAo#$fl%r?`DYceYLaL&)Vu|vQ0)Z&0QV0)2$|EKu9@GMY zq!tBKNa_PAph;0kh>%1o7!)KbVlY91yi$oFgph>1?~aPqJMJ0Z==r|y{&BB+WQ?8t zSZmES=Ul%z*PL_b?+gx{Z@b(U1OmwryRS+VjbF^pA@BOwRY={`I$>Dd>5F<|pmSm_4?x3{`|SvYYt zbz4!uqOy5GAI*t;Z|j_)g4WShoA>U_Iazl+Y13za|6uRML-@MIr@uVwJybV$@r}-( z0&aLWpI%H2hF|HyP;2^o(jWOY1zXGy5ZM_$HQ}|1iffrP9)cqI$H)J-$JGrGqo%aK zLwDWqd4Ar@!%Z64ubPyayAdYkDyb{=Y!jmR)uZ;b^8wQeo{sE+BFKN$vq^xFggPh59sRt_|gtfXd5d>$Cb*)Of|Z%uhC0@=IQe;KNdx zTUM~iy_`Fcuha?HA~c#@7_G00-L?8?nFrtP?%B8`F#HXXb)`E%cTvkH#h>-0PJFkl z*alIuHQBj%Yj9D1T9tcp4H@i!HqDtC*y$6m%7NRGuG=Q(dF6dzvc}?cnHJsR@@g`< z9p9|UiX%Ly@K4QM0+lcSbC!+B_?GUH&wI>2B(`*0<{%KS+jxB@jGXw+v%7RX2WX>1 z)PX;zyq-?Vcl2um??*nYZwqqxqK>h$m{Oa(t3AN8+w6cyA}9!wOoei-4WetY7}uG; zb7Dq+Z?ng9T*EB$E7rvM8=pFkAJ6}N6*FELa>_rlt6l}w_63eF@vX9lvfU|^^lW7T zIW1M<_NMrDPE2fj$=bdXlUY_-i}A zQ6hLclm+>#W@XQls;_6D<9_>fDftBtQ1o~qhaSOIs%FucyILi#+%^RGih(R zFeCkiK3T7o`s2BOD(c$=J32Z-Y%?LpX7us?px2)i{H1=s`;tmF>v)D-@^mga^~cm{ z;Ah?`U-5K&)lBbw&->i7NaPSz&NRCu`Z?9OBStHTER;PBCVw`?H)rptE{fTmIAc3{& z4Sx8ra^jzPWnJxjCR{j#&X&WwZC_6x|J?%sDNnkc-Zjx$wlvzbEx5D0)LYI)#uvTW zxy(GD>b5^bE$O~p*TdcI5U^{qkDF~c)l@p8=V{k)DVBiRMSPN6NRp%Yv>x+rx^@U+ znTEW@4HtW;@~mGETwF7ps(h`N!zXTc=15gEi&5sxq(x1*nfRx=GfC1+bEJ7cqU#y$ z`&S0O-Q^U{GN0;AdIqPLk0364j1bK(9k15PWcxpCsa)@6mF`*FLT6T5yUXI&ym(vX z_d7s6I8zTIHa940{xtq4@+JTEvOw5F0SzpLd)<_bHoUE@?cpPju0%@~kNext$o;~| z-Bgl_|NCEE$OQ!j(!bj1FRi_|*EB2TiJtOwe9J4c7y3jQUz^4DSvV|Ksa*j-Amk)x z=FYfS?d(xtvg98CCxAWB({ytNa)yzrUNj~9;+l*Hg8r4OXYfmU#*3%BYndJ#92}wd zzRzgozR{%IIzn*_8>a)Bat>s&USUgKIbr?!_2&Bkn37m)vtl)3znT8#1{oxS>~z^9 z9R6AW)#%B(ZEHPnoskyNU<{ z2vqd3@0q6byPDP(o;unxnCfrQK{$T{T;)C7Klalv4zQbvx<$a{A)P%GIDzI?4a@>g z-rhEL!0ATITENE!fj$gwnGJ$2ip^GcmkRt1D@z$Xl7TB%PCu)X;H|C_O-eP~$Q~M0 z79;$z@ze}I&<&rR6Ce=f$eQoZH{z9TQiYgmgJ_?WYUBu{zj-=ZA-)8R)O9*%E~rs? z{vv+#bf#n>udQ1ln@3%6H25TRmu46Z5OIJG5O(9ZBd=kxO;?paKmY=!E;4LCQ<>vh zlt3N|1Sl)tV103@Ema<{sd!&7vBF@S1Ke1=CJS_Eg}dz;7SEJ#ICh5F3HW8k)QNga zdC==t7t}|COT2Ul#?^rY3#5LtlTdKMO4unYVRh#V^FuCqq;5861glp-msp4CdO^>U z${hyD{RCEgr1kBT_pDD`Og$(QrEPGv<@D~8U)X{{1FNk(@Oq%Im>ExWRp0LFoHB?) zq&*Rq0-$$qDA4FX@fZX;Z~$ls$m4J*&=dr6DhJ*Kfd&TtkK*~OO`o*qNNZ81z7d8+ z54Vo7^yBNfHN}nVy_f^K_CRyXh$D9*)k*_B?1v9+sqbGMsMc6@CM!lXMut%U-lve7z59) z`Y9g{sfTe%leb-WcdW4THO3xV42W{TIJGaRW_xMqowx4>u+xAQ_T%Q&V7`VsjbHFq z6ToGhJ8tizL@*|4=n;6^F?k(N@~G6yCou2)s63Ttp<(wuD@Mmz0B6);6CT-gs|-F` z$i@*bUvR&uzq(d4f;{Kz-=*YgvxOYb9`)R#_8`Dj68r;@k~e%8399OBQk+~FSrmX( zJ`7bfhW=2$>Z=l`Wc*?jYhNIIHH4-5?*7lx0=VJ#i;%@4oSOgL>29j4rmbx03RzyZ zGX8ljeN{nCMo|XUjUcL{={o0WnX^E`pq35!faJS^N2rui3XC7A2%P)em$a z`xOtH>$e6GSMnNEch>QURgLY*$$NjUyc31*c7SF-E*7b74RHrC@6z> zEtcl-&%rW_=CjQibnd`2MwRcS%7IyjRIju^TIs&~>H^)sT%I=g{^GW6B`YFZhle6< z5Yb&>aY*}}5M8^SBpR1gz(98~%kTJz8>VEaby<202-HQ%iC+XrtGu*kAWyRW_GcG7 zzSm#9;IY33Jt)X>661E1xChw?sw=ls*FEYh*@Q(VVFY3n1}sLA4UH>>sM(+cq?vh5 zjRc{Zr_lPrRS;I?SMb%Ni9r`KqN%O|amnXCmn4t;KU6k)oUW1OF~nFb@^ZE0kt5Vu zieWf2+sH?*ubc})dc2Y<1Z5fv$~<sgiFqemS{gZ<_O82!Cmu7aD~lYXSP1lN{6B2twH*&zO4IJd&* z8&q&hg;${sh@Sg$;vmqc$KL(>_jvF#?vpI@7$XH`zQZss2Eb@f)%3C^{bRxxfEcB_ zCyWmf*>^`uxl_f!vm0BE(XK3kcGyD??`sK_Td^zn!a!uzz_J}LfXuKq>(Sb*IWHjh z2FaG1ZcA->0l2@>a^fp;;y(qn^xi?hig`fD#w(?)RPy;g2sK;dx1L7QSG=%>Mbo~_ zaP+K!UFT-{y%A0rXWJoTk;p#jZC3#M)IEsOAx4zLhml`~q2t`^E^#UYPC8=&5NO~v z?0fEj<=s5Yq@|&9fQ1h(dOdK2`}U*UkA7vs+ShK~GIH|cp_=Xa-$q~d3_9C@H`GN` zx<{vpANbbP!2lwmPBsEx8^Im`o#;{Fqu;Ubo44IQT^|Ii*~I^7}1oid12`=0(5mQv5(zjh&?Ymy_3a;eHra(bO+1ilW# zXGs%;OHoohF1p=EUxy_k5ryQZM`?{L1~1k5QJEpMzaS%7Zpvq%qp7@@XfjIV^E->! z;n)9LQHH6`V?4a87#CrK;AxQP&XuZR&fn{bmP+XE8flaW^OZ@7U1<#xJZYjfn|o>6 ze}v0y4#Di9GC+cWOVXIAJp4oSf?>N+xt&-UpM-(%&6?bCO|GHO;i}WGw#U6!3mipP zf9aulNRue}K0{dhIiB3>Mcyc?7g$3?z(^N&n zaPJtm)z@H{`eox3KU`QYOJum$3;ReFxYks=5onM!*kmU5NeqoeYgz^c8H8AA1UC&# zUO0DP`b{3r&O|MkTD7@7J9(i!1x32t%hGPi{GZxs!~gHg^G<&##j+kwIisjc$t*P1s9%Qj{P#s~W|p zk^Nmc&7)9qxyvXeq{h7_jzA{IT`>{a=1N(0(bw2ytEK`V_-c_wxRZXNZ~sX%SJCT; z7KMDIVZrZ3jBGY9kbs4omG1*>q-7l=hMyGN-4ktcp{l>DNe)p$C;4 z1kHwiP7z!+?L?!L+MXzL72LN{B6DX-BBdMu`WC{{4rqp6UhiWhV zxo~SqzZ5~b#E@z94uf}4PDXV2`l(f0Q&KBFTitQUZNZ=umhQH@gsH|H~`F4tuvzm#^jkZcKFeGa{w)V zjWo!Hv0Q`k94zS2^xmeGi}|hGjA(GT+uTwfOooEq;KKhK|!=DnzAqgC1 zx&eC8?3>1Qa^$zAe$ob)CbHU)@`j5`yJNTH34;tvL7F$zx4~u;kK?|0Fn2twT&#x- z0<(&CXzjxm^*Ih&2BD+vcv1FR=^q70%Ng!LHOb0!LbB3(G?_o#hwp0cCnY7NG7=JY zbQMn;Juk%sfoalt?2_IRZKZqgWLbuJ%(9x3O^-tv$i-j-Zd}tihR+MR#OYrkh$hhQ zp#t+fJ;|NL+AEy7@&+cqQnP&X$t{fL$&Qh4L0L;*v+jV~uCS?9^FaqpuT^`1*p~Y1 z5n?%S3E-R`QvHea(rUeH@Hvxf66l6*=gcYvob5lG0(J!c8bU#!UPQ~a;Fb+#FPv~q z=({E_aP5C4$0|ZwR8q3}bcZn35D9n)-jPPPNK>KD*%#>a8*W{ezgfwOG5_vC+yFd= z?lgW-J%D$ydX3a52WXE!aPyC~S-_$Ok^~z8BMSmWulaW`g-@-UA>D8>;-v$@miq53 zf;??=r15ZF)7L~6y^Q5ybv`m%g*|1V$Q~%@zsvp zG_^KHxK?QF+@Rokr52hv62mZOXx5(3Z4lpBH#t>SL+Kj^Y`-9p83CskZrx@s2?BNz zC+o!`e95i-*q_ltTk6icC)$F+#X@C^9Z4ZE!-jGdNwxF~G}l{Uyq3@_YXO^pqi2vn zW#6=fhED7_KTvIT6F!VA4A^y|=JyLiN0-Q~DH}wgjC|HLVC~Q1+RrM(mu$dNHQ0tL zsL+iF@=AFlxA8V%iMW;x+VjO*w0)OwV%1kC9m600eDqex!jlF=4zMlY*LO7xSe+YQ z|3e3;N&Vi99ahEz8ya9*vu~vTF^}rD{wgtne|d>Z(9u9>inOL2^CE*%OIrb=AGXdL z*hT}yf)1?I=eSPU1cv9dPKVl3%Pzo~nJG4ibi_eZAMo&P?uYGf9dh#rjEl_<<1)+r zkr#G?8SLOJ*YrA|RoG5!j13|-+9LL4U&1WZu`&o=Kbz6+{@j08=Zzk z{Wz>yV6zJ~5f)ceQ}#>{liV?s#_55c<1m?dW5Oj{@6Gct2Kab^kaOSQ4S<&O-d)aU z@jGt3Q-;@&_HQdgMzD>5W}*pD+{ws+uWA${F4gq+y-__{<%uxi-+tR_>g#Q~DP=A7Oz{guCEnZoDB%>vpf zduZy#SB`FIM~<{Xxc;9TwCg;KiWQ*~#@|(pQ^LQOQ!E-FcvqGqMZ+iI9hkpmEwC=~ zmyQqsTmKva!kQZZe6@!L&G=k^?ePB4#_rSNoVj1NTnl*j@_#os&P{HBs|H7AN*0>> z1nQj=H!O~dT|mYl(|FGg7E!bpZe!;a*_=r<3E_AhTfm7LhdcHw=rpS_dKnD~Z2}VT z{=ZIcS$Eg`+wqN;Pc5q}`L>(TuS#ND!gRA%l6M;zzXMSf{YX>4dTPb^vquf6^p;SV zAVHCxeR55frm0480v3!%;O`k8RMQiwp=}#SV8;zk+NR~KKx7ay)DU|xT+tn|3Kf50 zyv5CyT8n)`7W#~+v%<-Nr+Co)X@fyNNgfdp@`Qa91hhB7uA$Pd5bBIcJBfm^F5$MDgM0Y}%=1kckM+ zDDL9GV?_-&aC0*qGMalh^iE#wL6}xG%tp&iy|6g5H^MKqoFIe2w)U||{lpLB9ibRO zXhYY9T@%RTV;d&+`a422FjpS@Q7lCVb7DM8v{dUA`3ogydkBB5Rn&57n`xeWuk7y7 zvs6K-8;4TfZRil;nl)hj3$N_uE*BwpnnyJTZF`R4(;us5O@2zgeccuV$+KH0eGZ>u z#SVp&KN2+Zy+%A1V(g)Ym>%s{d1x>|Z?10BaE+DN9&@f|T-Pd>elKm~Gld_(!(AL+ ztFSJ)LwoloSL()5-2HTrkns%mIXIOKwF8YrC4|rrYc^^O#u!>8fK~cf}{st5G;7 znqjlQ9Y*V|7NE)V_dYY4>YN2JD}`MZLUXc2(^&xr%3e(OAtu(y8C9&g1!5e@7RT?L zcwQ>VwCEFgfg-H#%=>cfj>1$e?0d4>Bu>GM)X8NY%(jE1fw}(EKl+G;WI~Jyitl$4 zQxsY)!&2s<;TwRmm}E9g@#KsdcAgWDrDvrcPnP?}nR`)J;F<<;t)Z_-q1f70F*vQE z)@#4GET89zGKdoRQlorYqaW&BYI$nMJCI6NgFi3MktVj{#PCvOdJ+aT+a$Y?oqbCv z=iURKRgG}7vZdPZxT_69bRw$5C!Pz|HcRUz^QvPazHJ)yMs)TtoOxs%Opatz%c|fJ zWI?Sbn-ddCppN0AAM{rxQgiV=&>9>we%YZKEgRdij;Ypu1eW@!uzsj^9~?5NLk*aN zq7KhG2G7-`-%=>R_IN%!NpO-X7%Yw#-fU(gqj4#zln%^Y!&!=fjhioLI*C)xe%*2n z<)7;e6hHwNou{PFQ51PyHO&P@(dj#Vm|W>FR$i7tXu3q=B16e{gCqs=gSCWHDwePp zh%1Vd@pNQ7IXei5t-uZVG)cJyBD-T_v%P?rp$wj< zx1+_9dWWZtza+pxwLk=H5NkNcocia|p&@e5Ghug^kg;~8+yqM&hF@tw1rg{OWXsbt zvOGC<2bmrQ6UdFHG(NG{Bd(C{I#cE;(^FCvEf2~TV7_PZu|2pA1v&9NA{WqG_4VWi zhPv+&cNIkW#3Q2}%w(H14US@ta|L*w#_Htz( zQ{m-|stual_^_xp*2RZTc4sk^W_52-kO@=o8_{sZLe2M-YXj0(I(KyomWXgunC*BV zwtSf@G3cyia@TM5T7J)*!r%8lu{~-MDR-mLVu41XGPMKguAEA zL^gG-H*qCKTNx!28Lz#yaDe&}VyM9GYzA^11MeKdCQ(d1xzXu6*1^|^HV)TaDR<@b z8<$E*Ya+z@jcr5TsYQ|c8^_dBm~VVg^lB661E2W*o6vYadK89NLXzWm@TezMe5JDS z`lSr+9TBeFx@3A7K5kE{G7Jw?3*zV+4}zz^A{m}B$3{S^B2q5)KH`E6SY zKnH~=^_d_~gyCSUZv@YMGzoRV1eIgpl-tQYXSUF<`J%{h9D(7>lz=gX4NS#1Y$o#N z1Dzh*W7tISNFx#;o5_|mKs(bobv$3hA42>cbp(1+tpjpV80|{00szax7##CPr%7GP z2*y|X4CSKB?uL{OmYLOMeCA}IG9F6r3Qv~t;zgL_eEhh^D)}pG1$9%VQ`%3Zg}7T- zwx*By1EYlQDXkv%)yrz9q}9v_v|biM)(l9xu6d1d2+Bi%ce* zlPEn*=p>T&08Nr96>pjL(3ZuNp}R1DZ;_XVIFt(ajw2h+A+sCRkdr}r0KD)byYo(= zKp6C-umoM=zW`EC{cUO!GW+khjB;X6l%Zh?BS7m_;c0YD6y%3*e8>ck!7^e|?MAJB zV{WD4VG8OXcyp5nFE;w-_qJ5iB>n>c%VoU4n&ci#b@p+lSkFG_69I7E1O(H^SZ(F< z2DK_oh`n=gb2@hcKU^Fg#Ak}p$Lk(x2$xaOYGe4&BsMmljO4)^v>w9l{!+7Vjke+* zO>t7jxYcS@#?}lxmiC$Rcl$HyI4f1Z@La+eCx{hHoLVnLZ(J=rDP;gm#_;0Pb`Y-g zqz_|FNY1xGYSR*aYn@L#^+H9eJk~u1&C_fr(>^ibYves;m9jA!px$6d8GIi(k=9h{ z$%gUUnBU?Ev`M0sy@Z2zva286MXRZjrEf^SHl}??0!v*=3UA%{=#bxANr(c?w()ix|3=xCO=y)GWY~9M5}xb-h+|u z;fQy1hK;#nq$3*el(4_R6FWY=!3-nNm4G$U)W$A0$<{$w5|q-&*Y0b&LVm&=8Wi+B z@*7e2kp*GW8s-dFcRwlKp3dF*;$2hgc>!QW8JSbKpZE_t6`U}+ntL+kU9AA zjOg+UWJv;l6bYb6I$7myBk-3_Ln4c${xk)VQ_qkXN#C==87F{*M8nWQldNueH*Z@l zE?g`U9wumQ`Y&D>khXa56k<)kif4^$lTscUWqV z21!WNjx^&_LkxZH#}kBoU|~v!dP=w=j_X9QvUOLCBI!-00zMru?8w|NEl(Hmry%R` zJ1i{^UqtU0l~HOp*6ZyqmR~dNq%+mBBEAXb6xUVe*L6aiM&Mog7Uv(U|0q_!pb(26 zADjIJkfZ4BqRA92HRdaMLtB~oV3a4K34Tkf{fK#`f)|A=N>C8Ulu0ZB9);&|l`DAr zu$_Q}O~E?wGt^?`@;poXclJa%<`;5urMY26jY85O482@MHc7RiewQQwgbN=DuKi%r znY!s~nQFx;lP<(bNG9J>MjqQOl`By6a~zt96BT87&}e3Vjgj3>RyLA9j5B9M)7hNy zdjg;a4~b4E;|g=)X*jp6$%bOaW zkCpqJaa?WSptu{RqwE?hoO0O>gkGGc@qeh^3#YwNEzzfF9Uje*Bg_b%3&A z{#ldr{rjhfuqhp02IH6CQ{I>7?jU)Oa2Hq{r8?*PU}1qB!+f*K@>{gX0mTwJP;gyN z#W<;M^57Q2U1y7AfoMpi-1}_Hr!46~Jz@uJDpsopIP>0D@uCJ_2y-OJ3pd&!kr_M>!2)&i!SPzaF@mR zdlXAvF4yb?tgIol>poe~I;7disdw)ZTLYZ+LWjZYHS%+0#&dyij3?M^+%r|n(?9kz zsK6|Ps?S*qwU_q+!F&B(WQp4_ZSBr%a+}$Bgr8Geg#$yDto+7}7Gx&+o7FcKu zhLG%ht5uU2vkN?ItScGgju7SAy-hITQt53xnwkZdDdLN6Z#=32=W0xTYN7pL)Fd@m zV>}m=s_xSWPid<-ydKS%|BsqR5(iuK6FQAdfP@fMvYDFRtUduv)k-tr8Z%He>BH&< z>Dd>N+*fhfeXm@8@qVl{=>!&+S~xy^XuBIfwE>v3{ZUfW^f{1I#mk1Qa)}{ zSngFQB*bz{0UMGPTs81d^aRPPdV6?P182K|O>ZDURCA;$7eZL(op&~5B1|}^ylM=HiUC;y z`n-WKTdLRVoF`Cf_v?#xFViSMrpx`^H7( literal 0 HcmV?d00001 From 2304a73dfa80256efa15616c40e59b54b2330a4d Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Sat, 8 Feb 2025 13:13:22 -0500 Subject: [PATCH 02/29] work on configurable and extensible rules --- .../Controllers/WeatherForecastController.cs | 4 +- .../Middleware/GeoTokenDiscriminator.cs | 13 ++++++ RateLimiter.Tests.Api/appsettings.json | 46 ++++++++++++++++++- .../Rules/FixedWindowRuleTests.cs | 7 ++- .../Abstractions/IProvideADiscriminator.cs | 9 ++++ .../Abstractions/IRateLimitRequests.cs | 4 +- RateLimiter/Abstractions/IRateLimitRule.cs | 6 ++- RateLimiter/Config/LimiterDiscriminator.cs | 11 +++++ RateLimiter/Config/LimiterType.cs | 7 +++ ...{RateLimited.cs => RateLimitedResource.cs} | 17 +------ .../Discriminators/ApiKeyDiscriminator.cs | 16 +++++++ .../Discriminators/GeoBasedDiscriminator.cs | 16 +++++++ .../Discriminators/IpAddressDiscriminator.cs | 16 +++++++ .../Middleware/RateLimiterMiddleware.cs | 11 +++-- RateLimiter/RateLimiter.cs | 9 +++- RateLimiter/RateLimiterRulesFactory.cs | 3 ++ RateLimiter/Rules/FixedWindowRule.cs | 6 +-- .../Rules/FixedWindowRuleConfiguration.cs | 18 ++++++++ 18 files changed, 187 insertions(+), 32 deletions(-) create mode 100644 RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs create mode 100644 RateLimiter/Abstractions/IProvideADiscriminator.cs create mode 100644 RateLimiter/Config/LimiterDiscriminator.cs create mode 100644 RateLimiter/Config/LimiterType.cs rename RateLimiter/Config/{RateLimited.cs => RateLimitedResource.cs} (51%) create mode 100644 RateLimiter/Discriminators/ApiKeyDiscriminator.cs create mode 100644 RateLimiter/Discriminators/GeoBasedDiscriminator.cs create mode 100644 RateLimiter/Discriminators/IpAddressDiscriminator.cs create mode 100644 RateLimiter/Rules/FixedWindowRuleConfiguration.cs diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs index 51b88861..97cb1144 100644 --- a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -19,8 +19,8 @@ public WeatherForecastController(ILogger logger) _logger = logger; } - [RateLimited(LimiterType = LimiterType.RequestsPerTimespan, Config = "nameOfTheConfigEntry", Discriminator = LimiterDiscriminator.IpAddress)] - [RateLimited(LimiterType = LimiterType.RequestsPerTimespan, Config = "nameOfTheConfigEntry", Discriminator = LimiterDiscriminator.GeoLocation)] + [RateLimitedResource(RateLimiterRuleName = "GeoBased")] + [RateLimitedResource(RateLimiterRuleName = "RequestPerTimespan-Default")] [HttpGet(Name = "GetWeatherForecast")] public IEnumerable Get() { diff --git a/RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs b/RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs new file mode 100644 index 00000000..8bbec5a0 --- /dev/null +++ b/RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs @@ -0,0 +1,13 @@ +using RateLimiter.Abstractions; + +namespace RateLimiter.Tests.Api.Middleware +{ + public class GeoTokenDiscriminator : IProvideADiscriminator + { + public string GetDiscriminator(HttpContext context) + { + // get the token + return context.Request.Query["apiKey"].FirstOrDefault() ?? string.Empty; + } + } +} diff --git a/RateLimiter.Tests.Api/appsettings.json b/RateLimiter.Tests.Api/appsettings.json index 10f68b8c..c2631586 100644 --- a/RateLimiter.Tests.Api/appsettings.json +++ b/RateLimiter.Tests.Api/appsettings.json @@ -5,5 +5,49 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "RateLimiter": { + "RateLimitingAlgorithm": "FixedWindow", + "Rules": [ + { + "Name": "RequestPerTimespan-Default", + "Type": "RequestPerTimespan", + "Discriminator": "IpAddress", + "DiscriminatorMatch": "*.*.*.*", + "MaxRequests": 3, + "TimespanMilliseconds": 3000 + }, + { + "Name": "TimespanElapsed-Default", + "Type": "TimespanElapsed", + "Discriminator": "IpAddress", + "DiscriminatorMatch": "*.*.*.*", + "TimespanMilliseconds": 5000 + }, + { + "Name": "GeoBased", + "Type": "TimespanElapsed", + "Discriminator": "GeoLocation", + "DiscriminatorMatch": "US|FR|MX|TH", + "TimespanMilliseconds": 5000 + }, + { + "Name": "GeoTokenRule-US", + "Type": "RequestPerTimespan", + "Discriminator": "Custom", + "DiscriminatorMatch": "US", + "CustomDiscriminatorType": "GeoTokenDiscriminator", + "MaxRequests": 3, + "TimespanMilliseconds": 1000 + }, + { + "Name": "GeoTokenRule-EU", + "Type": "TimespanElapsed", + "Discriminator": "Custom", + "DiscriminatorMatch": "EU", + "CustomDiscriminatorType": "GeoTokenDiscriminator", + "TimespanMilliseconds": 500 + } + ] + } } diff --git a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs index 2d12fc0f..2072129a 100644 --- a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs +++ b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs @@ -13,7 +13,12 @@ public class FixedWindowRuleTests [Fact] public void WhenFoo_DoesBar() { - var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(3)); + // TODO: Turn this into a theory and attempt to handle edge cases + var rule = new FixedWindowRule(new FixedWindowRuleConfiguration() + { + MaxRequests = 3, + WindowDuration = TimeSpan.FromSeconds(3) + }); // First 3 requests allowed rule.IsAllowed("client1").Should().BeTrue(); diff --git a/RateLimiter/Abstractions/IProvideADiscriminator.cs b/RateLimiter/Abstractions/IProvideADiscriminator.cs new file mode 100644 index 00000000..9bd45279 --- /dev/null +++ b/RateLimiter/Abstractions/IProvideADiscriminator.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace RateLimiter.Abstractions +{ + public interface IProvideADiscriminator + { + string GetDiscriminator(HttpContext context); + } +} diff --git a/RateLimiter/Abstractions/IRateLimitRequests.cs b/RateLimiter/Abstractions/IRateLimitRequests.cs index 1621dfad..36df66dc 100644 --- a/RateLimiter/Abstractions/IRateLimitRequests.cs +++ b/RateLimiter/Abstractions/IRateLimitRequests.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; -namespace RateLimiter; +namespace RateLimiter.Abstractions; public interface IRateLimitRequests { - (bool, string) IsRequestAllowed(IEnumerable rules); + (bool, string) IsRequestAllowed(IEnumerable rateLimitedResources); } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRule.cs b/RateLimiter/Abstractions/IRateLimitRule.cs index 3f6a04ca..9ef4a9ca 100644 --- a/RateLimiter/Abstractions/IRateLimitRule.cs +++ b/RateLimiter/Abstractions/IRateLimitRule.cs @@ -1,6 +1,10 @@ -namespace RateLimiter.Abstractions; +using RateLimiter.Config; + +namespace RateLimiter.Abstractions; public interface IRateLimitRule { bool IsAllowed(string discriminator); + + LimiterDiscriminator Discriminator { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Config/LimiterDiscriminator.cs b/RateLimiter/Config/LimiterDiscriminator.cs new file mode 100644 index 00000000..4efafab2 --- /dev/null +++ b/RateLimiter/Config/LimiterDiscriminator.cs @@ -0,0 +1,11 @@ +namespace RateLimiter.Config; + +public enum LimiterDiscriminator +{ + ApiKey, + Custom, + GeoLocation, + IpAddress, + IpSubNet, + RequestHeader +} \ No newline at end of file diff --git a/RateLimiter/Config/LimiterType.cs b/RateLimiter/Config/LimiterType.cs new file mode 100644 index 00000000..e9c899d9 --- /dev/null +++ b/RateLimiter/Config/LimiterType.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Config; + +public enum LimiterType +{ + RequestsPerTimespan, + TimespanElapsed +} \ No newline at end of file diff --git a/RateLimiter/Config/RateLimited.cs b/RateLimiter/Config/RateLimitedResource.cs similarity index 51% rename from RateLimiter/Config/RateLimited.cs rename to RateLimiter/Config/RateLimitedResource.cs index a8332e30..42212886 100644 --- a/RateLimiter/Config/RateLimited.cs +++ b/RateLimiter/Config/RateLimitedResource.cs @@ -3,24 +3,11 @@ namespace RateLimiter.Config; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class RateLimited : Attribute +public sealed class RateLimitedResource : Attribute { public LimiterType LimiterType { get; set; } - public string Config { get; set; } + public string RateLimiterRuleName { get; set; } public LimiterDiscriminator Discriminator { get; set; } -} - -public enum LimiterType -{ - RequestsPerTimespan, - TimespanElapsed -} - -public enum LimiterDiscriminator -{ - IpAddress, - GeoLocation, - IpSubNet } \ No newline at end of file diff --git a/RateLimiter/Discriminators/ApiKeyDiscriminator.cs b/RateLimiter/Discriminators/ApiKeyDiscriminator.cs new file mode 100644 index 00000000..53bcacdf --- /dev/null +++ b/RateLimiter/Discriminators/ApiKeyDiscriminator.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; + +using RateLimiter.Abstractions; + +using System; + +namespace RateLimiter.Discriminators +{ + public class ApiKeyDiscriminator : IProvideADiscriminator + { + public string GetDiscriminator(HttpContext context) + { + throw new NotImplementedException(); + } + } +} diff --git a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs new file mode 100644 index 00000000..ba3b6ba1 --- /dev/null +++ b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; + +using RateLimiter.Abstractions; + +using System; + +namespace RateLimiter.Discriminators +{ + public class GeoBasedDiscriminator : IProvideADiscriminator + { + public string GetDiscriminator(HttpContext context) + { + throw new NotImplementedException(); + } + } +} diff --git a/RateLimiter/Discriminators/IpAddressDiscriminator.cs b/RateLimiter/Discriminators/IpAddressDiscriminator.cs new file mode 100644 index 00000000..67f6dc76 --- /dev/null +++ b/RateLimiter/Discriminators/IpAddressDiscriminator.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; + +using RateLimiter.Abstractions; + +using System; + +namespace RateLimiter.Discriminators +{ + public class IpAddressDiscriminator : IProvideADiscriminator + { + public string GetDiscriminator(HttpContext context) + { + throw new NotImplementedException(); + } + } +} diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs index a55ffce7..bfeda612 100644 --- a/RateLimiter/Middleware/RateLimiterMiddleware.cs +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -1,10 +1,11 @@ -using System.Linq; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; +using RateLimiter.Abstractions; using RateLimiter.Config; +using System.Linq; using System.Threading.Tasks; namespace RateLimiter.Middleware; @@ -29,14 +30,14 @@ public async Task Invoke(HttpContext context) { var endpoint = context.Features.Get()?.Endpoint; - var attributes = endpoint?.Metadata.GetOrderedMetadata(); + var rateLimitedResources = endpoint?.Metadata.GetOrderedMetadata(); - if (attributes is not null && attributes.Any()) + if (rateLimitedResources is not null && rateLimitedResources.Any()) { // TODO: Do not default to this discriminator var token = context.Request.Query["clientToken"]; - var (isAllowed, message) = _rateLimiter.IsRequestAllowed(attributes); + var (isAllowed, message) = _rateLimiter.IsRequestAllowed(rateLimitedResources); if (!isAllowed) { diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 5ab49ac3..a4c8188b 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -16,9 +16,14 @@ public RateLimiter( _rules = rulesFactory.GetRules(); } - public (bool, string) IsRequestAllowed(IEnumerable rules) + public (bool, string) IsRequestAllowed(IEnumerable rateLimitedResources) { - var passed = _rules.All(rule => rule.IsAllowed("foo")); + // get the matching rules + var rules = _rules.Where(r => rateLimitedResources.Select(x => x.Discriminator) + .ToList().Contains(r.Discriminator)); + + // ensure they all pass + var passed = rules.All(x => x.IsAllowed(x.Discriminator.ToString())); return passed ? (passed, string.Empty) : (passed, "some message about banging on our door too much"); diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs index 60ecdf74..ecf97f65 100644 --- a/RateLimiter/RateLimiterRulesFactory.cs +++ b/RateLimiter/RateLimiterRulesFactory.cs @@ -8,6 +8,9 @@ public class RateLimiterRulesFactory : IProvideRateLimitRules { public IEnumerable GetRules() { + // Load built-in rules + + // Load rules defined via appSettings return new List(); } } \ No newline at end of file diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs index f0633148..5a141f54 100644 --- a/RateLimiter/Rules/FixedWindowRule.cs +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -11,10 +11,10 @@ public class FixedWindowRule : IRateLimitRule private readonly TimeSpan _windowDuration; private readonly ConcurrentDictionary _clientWindows; - public FixedWindowRule(int maxRequests, TimeSpan windowDuration) + public FixedWindowRule(FixedWindowRuleConfiguration configuration) { - _maxRequests = maxRequests; - _windowDuration = windowDuration; + _maxRequests = configuration.MaxRequests; + _windowDuration = configuration.WindowDuration; _clientWindows = new ConcurrentDictionary(); } diff --git a/RateLimiter/Rules/FixedWindowRuleConfiguration.cs b/RateLimiter/Rules/FixedWindowRuleConfiguration.cs new file mode 100644 index 00000000..8b8dd316 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRuleConfiguration.cs @@ -0,0 +1,18 @@ +using RateLimiter.Config; + +using System; + +namespace RateLimiter.Rules; + +public record FixedWindowRuleConfiguration +{ + public string Name { get; set; } + + public int MaxRequests { get; init; } + + public TimeSpan WindowDuration { get; init; } + + public LimiterDiscriminator Discriminator { get; init; } + + public string? CustomDiscriminatorType { get; init; } +} \ No newline at end of file From 4d34a93dcee3354f3b9f8192dee85c292a3fbfaa Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Sun, 9 Feb 2025 06:00:01 -0500 Subject: [PATCH 03/29] more skeleton work. begin implementation now --- .../Controllers/WeatherForecastController.cs | 4 ++-- RateLimiter.Tests/RateLimiterTest.cs | 8 ++++++-- RateLimiter/Abstractions/IRateLimitRule.cs | 2 ++ RateLimiter/Config/RateLimitedResource.cs | 2 +- RateLimiter/Discriminators/GeoBasedDiscriminator.cs | 9 ++++++--- RateLimiter/RateLimiter.cs | 12 ++++++++++-- RateLimiter/Rules/FixedWindowRule.cs | 3 +++ RateLimiter/Rules/LeakyBucketRule.cs | 3 +++ RateLimiter/Rules/SlidingWindowRule.cs | 3 +++ RateLimiter/Rules/TokenBucketRule.cs | 3 +++ 10 files changed, 39 insertions(+), 10 deletions(-) diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs index 97cb1144..ca8b181e 100644 --- a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -19,8 +19,8 @@ public WeatherForecastController(ILogger logger) _logger = logger; } - [RateLimitedResource(RateLimiterRuleName = "GeoBased")] - [RateLimitedResource(RateLimiterRuleName = "RequestPerTimespan-Default")] + [RateLimitedResource(RuleName = "GeoBased")] + [RateLimitedResource(RuleName = "RequestPerTimespan-Default")] [HttpGet(Name = "GetWeatherForecast")] public IEnumerable Get() { diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 5c8b0f71..b2faad28 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -8,8 +8,12 @@ namespace RateLimiter.Tests; public class RateLimiterTest { [Fact] - public void Example() + public void WhenFoo_DoesBar() { - true.Should().BeFalse(); + // arrange + + // act + + // assert } } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRule.cs b/RateLimiter/Abstractions/IRateLimitRule.cs index 9ef4a9ca..488d43dd 100644 --- a/RateLimiter/Abstractions/IRateLimitRule.cs +++ b/RateLimiter/Abstractions/IRateLimitRule.cs @@ -4,6 +4,8 @@ namespace RateLimiter.Abstractions; public interface IRateLimitRule { + string Name { get; set; } + bool IsAllowed(string discriminator); LimiterDiscriminator Discriminator { get; set; } diff --git a/RateLimiter/Config/RateLimitedResource.cs b/RateLimiter/Config/RateLimitedResource.cs index 42212886..96939ac6 100644 --- a/RateLimiter/Config/RateLimitedResource.cs +++ b/RateLimiter/Config/RateLimitedResource.cs @@ -7,7 +7,7 @@ public sealed class RateLimitedResource : Attribute { public LimiterType LimiterType { get; set; } - public string RateLimiterRuleName { get; set; } + public string RuleName { get; set; } public LimiterDiscriminator Discriminator { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs index ba3b6ba1..77238e18 100644 --- a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs +++ b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs @@ -2,15 +2,18 @@ using RateLimiter.Abstractions; -using System; - namespace RateLimiter.Discriminators { public class GeoBasedDiscriminator : IProvideADiscriminator { public string GetDiscriminator(HttpContext context) { - throw new NotImplementedException(); + // get the ip address via cache/external source + + // perform a geo lookup on it + + // return the geolocation + return "US"; } } } diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index a4c8188b..be833b15 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -18,9 +18,12 @@ public RateLimiter( public (bool, string) IsRequestAllowed(IEnumerable rateLimitedResources) { + // need to get the discriminator for each incoming rale limit configuration + var discriminators = GetDiscriminators(); //key: name value: discriminatorValue + // get the matching rules - var rules = _rules.Where(r => rateLimitedResources.Select(x => x.Discriminator) - .ToList().Contains(r.Discriminator)); + var rules = _rules.Where(r => rateLimitedResources.Select(x => x.RuleName) + .ToList().Contains(r.Name)); // ensure they all pass var passed = rules.All(x => x.IsAllowed(x.Discriminator.ToString())); @@ -28,4 +31,9 @@ public RateLimiter( return passed ? (passed, string.Empty) : (passed, "some message about banging on our door too much"); } + + private List<(string, string)> GetDiscriminators() + { + return [("foo", "bar)")]; + } } \ No newline at end of file diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs index 5a141f54..5be4fcf3 100644 --- a/RateLimiter/Rules/FixedWindowRule.cs +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; +using RateLimiter.Config; namespace RateLimiter.Rules; @@ -34,4 +35,6 @@ public bool IsAllowed(string discriminator) return window.Count <= _maxRequests; } + + public LimiterDiscriminator Discriminator { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Rules/LeakyBucketRule.cs b/RateLimiter/Rules/LeakyBucketRule.cs index 4b8e6a3b..545aeaec 100644 --- a/RateLimiter/Rules/LeakyBucketRule.cs +++ b/RateLimiter/Rules/LeakyBucketRule.cs @@ -1,6 +1,7 @@ using RateLimiter.Abstractions; using System; +using RateLimiter.Config; namespace RateLimiter.Rules; @@ -10,4 +11,6 @@ public bool IsAllowed(string discriminator) { throw new NotImplementedException(); } + + public LimiterDiscriminator Discriminator { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/SlidingWindowRule.cs index 614318e4..a5895d2c 100644 --- a/RateLimiter/Rules/SlidingWindowRule.cs +++ b/RateLimiter/Rules/SlidingWindowRule.cs @@ -1,6 +1,7 @@ using RateLimiter.Abstractions; using System; +using RateLimiter.Config; namespace RateLimiter.Rules; @@ -10,4 +11,6 @@ public bool IsAllowed(string discriminator) { throw new NotImplementedException(); } + + public LimiterDiscriminator Discriminator { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Rules/TokenBucketRule.cs b/RateLimiter/Rules/TokenBucketRule.cs index f1f389b0..c564c686 100644 --- a/RateLimiter/Rules/TokenBucketRule.cs +++ b/RateLimiter/Rules/TokenBucketRule.cs @@ -1,6 +1,7 @@ using RateLimiter.Abstractions; using System; +using RateLimiter.Config; namespace RateLimiter.Rules; @@ -10,4 +11,6 @@ public bool IsAllowed(string discriminator) { throw new NotImplementedException(); } + + public LimiterDiscriminator Discriminator { get; set; } } \ No newline at end of file From 09946e21a27c8b92cdaa5bfb73eccca553bc6ed0 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Mon, 10 Feb 2025 05:49:07 -0500 Subject: [PATCH 04/29] configuration-based working; needs a lot of cleanup and extensibility --- .../Controllers/WeatherForecastController.cs | 5 +- RateLimiter.Tests.Api/Program.cs | 4 + .../Properties/launchSettings.json | 2 +- RateLimiter.Tests.Api/appsettings.json | 25 +-- RateLimiter.Tests/RateLimiter.Tests.csproj | 4 +- RateLimiter.Tests/RateLimiterTest.cs | 118 +++++++++++++- .../Rules/FixedWindowRuleTests.cs | 14 +- RateLimiter.sln.DotSettings.user | 4 +- .../Abstractions/IDefineRateLimitRules.cs | 18 +++ .../Abstractions/IProvideDiscriminators.cs | 12 ++ .../Abstractions/IProvideRateLimitRules.cs | 6 +- .../Abstractions/IRateLimitRequests.cs | 6 +- ...imitRule.cs => IRateLimitRuleAlgorithm.cs} | 6 +- .../Config/RateLimitRuleConfiguration.cs | 13 -- RateLimiter/Config/RateLimitedResource.cs | 4 - .../Config/RateLimiterConfiguration.cs | 33 +++- .../RateLimiterRegister.cs | 16 ++ .../Discriminators/DiscriminatorProvider.cs | 53 +++++++ .../{Config => Enums}/LimiterDiscriminator.cs | 2 +- RateLimiter/{Config => Enums}/LimiterType.cs | 2 +- .../RateLimitingAlgorithm.cs | 3 +- .../Middleware/RateLimiterMiddleware.cs | 10 +- RateLimiter/RateLimiter.cs | 149 ++++++++++++++++-- RateLimiter/RateLimiterRulesFactory.cs | 48 +++++- .../Rules/{ => Algorithms}/FixedWindowRule.cs | 18 ++- .../FixedWindowRuleConfiguration.cs | 2 +- .../Rules/{ => Algorithms}/LeakyBucketRule.cs | 8 +- .../{ => Algorithms}/SlidingWindowRule.cs | 8 +- .../Rules/Algorithms/TokenBucketRule.cs | 20 +++ RateLimiter/Rules/RequestPerTimespanRule.cs | 25 +++ RateLimiter/Rules/TimespanElapsedRule.cs | 24 +++ RateLimiter/Rules/TokenBucketRule.cs | 16 -- 32 files changed, 579 insertions(+), 99 deletions(-) create mode 100644 RateLimiter/Abstractions/IDefineRateLimitRules.cs create mode 100644 RateLimiter/Abstractions/IProvideDiscriminators.cs rename RateLimiter/Abstractions/{IRateLimitRule.cs => IRateLimitRuleAlgorithm.cs} (58%) delete mode 100644 RateLimiter/Config/RateLimitRuleConfiguration.cs create mode 100644 RateLimiter/Discriminators/DiscriminatorProvider.cs rename RateLimiter/{Config => Enums}/LimiterDiscriminator.cs (79%) rename RateLimiter/{Config => Enums}/LimiterType.cs (68%) rename RateLimiter/{Config => Enums}/RateLimitingAlgorithm.cs (70%) rename RateLimiter/Rules/{ => Algorithms}/FixedWindowRule.cs (67%) rename RateLimiter/Rules/{ => Algorithms}/FixedWindowRuleConfiguration.cs (92%) rename RateLimiter/Rules/{ => Algorithms}/LeakyBucketRule.cs (53%) rename RateLimiter/Rules/{ => Algorithms}/SlidingWindowRule.cs (53%) create mode 100644 RateLimiter/Rules/Algorithms/TokenBucketRule.cs create mode 100644 RateLimiter/Rules/RequestPerTimespanRule.cs create mode 100644 RateLimiter/Rules/TimespanElapsedRule.cs delete mode 100644 RateLimiter/Rules/TokenBucketRule.cs diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs index ca8b181e..774b8cba 100644 --- a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; + using RateLimiter.Config; namespace RateLimiter.Tests.Api.Controllers @@ -19,8 +20,8 @@ public WeatherForecastController(ILogger logger) _logger = logger; } - [RateLimitedResource(RuleName = "GeoBased")] - [RateLimitedResource(RuleName = "RequestPerTimespan-Default")] + //[RateLimitedResource(RuleName = "GeoBased")] + [RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] [HttpGet(Name = "GetWeatherForecast")] public IEnumerable Get() { diff --git a/RateLimiter.Tests.Api/Program.cs b/RateLimiter.Tests.Api/Program.cs index 67e399be..e4883b56 100644 --- a/RateLimiter.Tests.Api/Program.cs +++ b/RateLimiter.Tests.Api/Program.cs @@ -1,3 +1,4 @@ +using RateLimiter.Config; using RateLimiter.DependencyInjection; var builder = WebApplication.CreateBuilder(args); @@ -7,6 +8,9 @@ builder.Services.AddSwaggerGen(); builder.Services.AddRateLimiting(); +builder.Services.Configure( + builder.Configuration.GetSection("RateLimiter")); + var app = builder.Build(); if (app.Environment.IsDevelopment()) diff --git a/RateLimiter.Tests.Api/Properties/launchSettings.json b/RateLimiter.Tests.Api/Properties/launchSettings.json index 8ccb1f87..d5b3f109 100644 --- a/RateLimiter.Tests.Api/Properties/launchSettings.json +++ b/RateLimiter.Tests.Api/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "http": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/RateLimiter.Tests.Api/appsettings.json b/RateLimiter.Tests.Api/appsettings.json index c2631586..10782c9d 100644 --- a/RateLimiter.Tests.Api/appsettings.json +++ b/RateLimiter.Tests.Api/appsettings.json @@ -7,38 +7,44 @@ }, "AllowedHosts": "*", "RateLimiter": { - "RateLimitingAlgorithm": "FixedWindow", + "DefaultAlgorithm": "FixedWindow", + "DefaultMaxRequests": 5, + "DefaultTimespanMilliseconds": 3000, "Rules": [ { - "Name": "RequestPerTimespan-Default", - "Type": "RequestPerTimespan", + "Name": "RequestsPerTimespan-Default", + "Type": "RequestsPerTimespan", "Discriminator": "IpAddress", "DiscriminatorMatch": "*.*.*.*", "MaxRequests": 3, - "TimespanMilliseconds": 3000 + "TimespanMilliseconds": 3000, + "Algorithm": "Default" }, { "Name": "TimespanElapsed-Default", "Type": "TimespanElapsed", "Discriminator": "IpAddress", "DiscriminatorMatch": "*.*.*.*", - "TimespanMilliseconds": 5000 + "TimespanMilliseconds": 5000, + "Algorithm": "LeakyBucket" }, { "Name": "GeoBased", "Type": "TimespanElapsed", "Discriminator": "GeoLocation", "DiscriminatorMatch": "US|FR|MX|TH", - "TimespanMilliseconds": 5000 + "TimespanMilliseconds": 5000, + "Algorithm": "Default" }, { "Name": "GeoTokenRule-US", - "Type": "RequestPerTimespan", + "Type": "RequestsPerTimespan", "Discriminator": "Custom", "DiscriminatorMatch": "US", "CustomDiscriminatorType": "GeoTokenDiscriminator", "MaxRequests": 3, - "TimespanMilliseconds": 1000 + "TimespanMilliseconds": 1000, + "Algorithm": "Default" }, { "Name": "GeoTokenRule-EU", @@ -46,7 +52,8 @@ "Discriminator": "Custom", "DiscriminatorMatch": "EU", "CustomDiscriminatorType": "GeoTokenDiscriminator", - "TimespanMilliseconds": 500 + "TimespanMilliseconds": 500, + "Algorithm": "Default" } ] } diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 7caa7239..5dc96218 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -8,8 +8,10 @@ + - + + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index b2faad28..ea167ded 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,8 +1,28 @@  +using AutoFixture; + using FluentAssertions; +using HttpContextMoq; +using HttpContextMoq.Extensions; + +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +using Moq.AutoMock; + +using RateLimiter.Abstractions; +using RateLimiter.Common; +using RateLimiter.Config; +using RateLimiter.Discriminators; +using RateLimiter.Enums; + +using System.Collections.Generic; +using System.Threading; using Xunit; +using static RateLimiter.Config.RateLimiterConfiguration; + namespace RateLimiter.Tests; public class RateLimiterTest @@ -10,10 +30,106 @@ public class RateLimiterTest [Fact] public void WhenFoo_DoesBar() { + var mocker = new AutoMocker(); + var fixture = new Fixture(); + // arrange + var appOptions = Options.Create(new RateLimiterConfiguration() + { + DefaultAlgorithm = RateLimitingAlgorithm.FixedWindow, + DefaultMaxRequests = 5, + DefaultTimespanMilliseconds = 3000, + Rules = GenerateRateLimitRules() + }); + //mocker.Use(appOptions); + + mocker.GetMock>() + .Setup(s => s.Value) + .Returns(appOptions.Value); + + mocker.Use(new DateTimeProvider()); + mocker.Use(new DiscriminatorProvider()); + + //// mock the rules as would be defined within appSettings + //var rateLimitRules = GenerateRateLimitRules(); + //mocker.GetMock() + // .Setup(s => s.GetRules(new RateLimiterConfiguration())) + // .Returns(rateLimitRules); + mocker.Use(new RateLimiterRulesFactory()); + // mock the rule attribute as would be applied to our resource's endpoint + var rateLimitedResources = new List() + { + fixture.Build() + .With(x => x.RuleName, "RequestPerTimespan-Default") + .Create() + }; + + var context = new HttpContextMock() + .SetupUrl("http://localhost:8000/path") + .SetupRequestHeaders(new Dictionary() + { + { "Host", "192.168.0.1"} + }) + .SetupRequestMethod("GET"); + + var limiter = mocker.CreateInstance(); + // act + const int numberOfRequestsToTry = 4; + + for (var i = 0; i < numberOfRequestsToTry; i++) + { + var result = limiter.IsRequestAllowed(context, rateLimitedResources); + + // assert + if (i <= 2) + { + result.RequestIsAllowed.Should().BeTrue(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + else + { + result.RequestIsAllowed.Should().BeFalse(); + result.ErrorMessage.Should().NotBeNullOrEmpty(); + + // wait 3 seconds + Thread.Sleep(3000); + + result = limiter.IsRequestAllowed(context, rateLimitedResources); + + result.RequestIsAllowed.Should().BeTrue(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + } + } + + private static List GenerateRateLimitRules() + { + var fixture = new Fixture(); + var values = new List + { + fixture.Build() + .With(x => x.Name, "RequestPerTimespan-Default") + .With(x => x.Type, LimiterType.RequestsPerTimespan) + .With(x => x.Discriminator, LimiterDiscriminator.IpAddress) + .With(x => x.DiscriminatorMatch, string.Empty) + .With(x => x.DiscriminatorRequestHeaderKey, string.Empty) + .With(x => x.MaxRequests, 3) + .With(x => x.TimespanMilliseconds, 3000) + .With(x => x.Algorithm, RateLimitingAlgorithm.Default) + .Create(), + fixture.Build() + .With(x => x.Name, "ApiKey-Default") + .With(x => x.Type, LimiterType.RequestsPerTimespan) + .With(x => x.Discriminator, LimiterDiscriminator.ApiKey) + .With(x => x.DiscriminatorMatch, string.Empty) + .With(x => x.DiscriminatorRequestHeaderKey, string.Empty) + .With(x => x.Algorithm, RateLimitingAlgorithm.Default) + .With(x => x.TimespanMilliseconds, 4000) + .Create() + }; - // assert + return values; } } \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs index 2072129a..2d6381e6 100644 --- a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs +++ b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs @@ -1,9 +1,11 @@ using FluentAssertions; using RateLimiter.Rules; +using RateLimiter.Rules.Algorithms; using System; using System.Threading; +using RateLimiter.Common; using Xunit; namespace RateLimiter.Tests.Rules @@ -14,11 +16,13 @@ public class FixedWindowRuleTests public void WhenFoo_DoesBar() { // TODO: Turn this into a theory and attempt to handle edge cases - var rule = new FixedWindowRule(new FixedWindowRuleConfiguration() - { - MaxRequests = 3, - WindowDuration = TimeSpan.FromSeconds(3) - }); + var rule = new FixedWindowRule( + new DateTimeProvider(), + new FixedWindowRuleConfiguration() + { + MaxRequests = 3, + WindowDuration = TimeSpan.FromSeconds(3) + }); // First 3 requests allowed rule.IsAllowed("client1").Should().BeTrue(); diff --git a/RateLimiter.sln.DotSettings.user b/RateLimiter.sln.DotSettings.user index 5341da72..66fbb056 100644 --- a/RateLimiter.sln.DotSettings.user +++ b/RateLimiter.sln.DotSettings.user @@ -1,4 +1,4 @@  - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="WhenFoo_DoesBar" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="C:\Projects\crexi\rate-limiter\RateLimiter.Tests" Presentation="&lt;test&gt;\&lt;RateLimiter.Tests&gt;" /> </SessionState> \ No newline at end of file diff --git a/RateLimiter/Abstractions/IDefineRateLimitRules.cs b/RateLimiter/Abstractions/IDefineRateLimitRules.cs new file mode 100644 index 00000000..fb290e1b --- /dev/null +++ b/RateLimiter/Abstractions/IDefineRateLimitRules.cs @@ -0,0 +1,18 @@ +using RateLimiter.Enums; + +namespace RateLimiter.Abstractions; + +public interface IDefineRateLimitRules +{ + LimiterType Type { get; } + + string Name { get; set; } + + LimiterDiscriminator Discriminator { get; set; } + + string? DiscriminatorRequestHeaderKey { get; set; } + + string? DiscriminatorMatch { get; set; } + + RateLimitingAlgorithm Algorithm { get; set; } +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IProvideDiscriminators.cs b/RateLimiter/Abstractions/IProvideDiscriminators.cs new file mode 100644 index 00000000..c6572a78 --- /dev/null +++ b/RateLimiter/Abstractions/IProvideDiscriminators.cs @@ -0,0 +1,12 @@ +using System.Collections; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; + +namespace RateLimiter.Abstractions; + +public interface IProvideDiscriminators +{ + Hashtable GetDiscriminators( + HttpContext context, + IEnumerable rules); +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IProvideRateLimitRules.cs b/RateLimiter/Abstractions/IProvideRateLimitRules.cs index 2709ae18..10f9ddbe 100644 --- a/RateLimiter/Abstractions/IProvideRateLimitRules.cs +++ b/RateLimiter/Abstractions/IProvideRateLimitRules.cs @@ -1,8 +1,10 @@ -using System.Collections.Generic; +using RateLimiter.Config; + +using System.Collections.Generic; namespace RateLimiter.Abstractions; public interface IProvideRateLimitRules { - IEnumerable GetRules(); + IEnumerable GetRules(RateLimiterConfiguration config); } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRequests.cs b/RateLimiter/Abstractions/IRateLimitRequests.cs index 36df66dc..e152d8b4 100644 --- a/RateLimiter/Abstractions/IRateLimitRequests.cs +++ b/RateLimiter/Abstractions/IRateLimitRequests.cs @@ -1,4 +1,6 @@ -using RateLimiter.Config; +using Microsoft.AspNetCore.Http; + +using RateLimiter.Config; using System.Collections.Generic; @@ -6,5 +8,5 @@ namespace RateLimiter.Abstractions; public interface IRateLimitRequests { - (bool, string) IsRequestAllowed(IEnumerable rateLimitedResources); + (bool RequestIsAllowed, string ErrorMessage) IsRequestAllowed(HttpContext context, IEnumerable rateLimitedResources); } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRule.cs b/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs similarity index 58% rename from RateLimiter/Abstractions/IRateLimitRule.cs rename to RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs index 488d43dd..c11eee56 100644 --- a/RateLimiter/Abstractions/IRateLimitRule.cs +++ b/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs @@ -1,12 +1,14 @@ -using RateLimiter.Config; +using RateLimiter.Enums; namespace RateLimiter.Abstractions; -public interface IRateLimitRule +public interface IRateLimitRuleAlgorithm { string Name { get; set; } bool IsAllowed(string discriminator); LimiterDiscriminator Discriminator { get; set; } + + RateLimitingAlgorithm Algorithm { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Config/RateLimitRuleConfiguration.cs b/RateLimiter/Config/RateLimitRuleConfiguration.cs deleted file mode 100644 index e354e3be..00000000 --- a/RateLimiter/Config/RateLimitRuleConfiguration.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace RateLimiter.Config; - -public class RateLimitRuleConfiguration -{ - // TODO: Spec out what needs to be configured for different rule types - // TODO: Determine different rule types and what they look like -} \ No newline at end of file diff --git a/RateLimiter/Config/RateLimitedResource.cs b/RateLimiter/Config/RateLimitedResource.cs index 96939ac6..2b4c43f9 100644 --- a/RateLimiter/Config/RateLimitedResource.cs +++ b/RateLimiter/Config/RateLimitedResource.cs @@ -5,9 +5,5 @@ namespace RateLimiter.Config; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] public sealed class RateLimitedResource : Attribute { - public LimiterType LimiterType { get; set; } - public string RuleName { get; set; } - - public LimiterDiscriminator Discriminator { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Config/RateLimiterConfiguration.cs b/RateLimiter/Config/RateLimiterConfiguration.cs index 9be41ddd..9ac436ea 100644 --- a/RateLimiter/Config/RateLimiterConfiguration.cs +++ b/RateLimiter/Config/RateLimiterConfiguration.cs @@ -1,6 +1,35 @@ -namespace RateLimiter.Config; +using RateLimiter.Enums; + +using System.Collections.Generic; + +namespace RateLimiter.Config; public class RateLimiterConfiguration { - public RateLimitingAlgorithm Algorithm { get; set; } + public RateLimitingAlgorithm DefaultAlgorithm { get; set; } + + public int DefaultMaxRequests { get; set; } + + public int DefaultTimespanMilliseconds { get; set; } + + public List Rules { get; set; } + + public class RateLimiterRuleItemConfiguration + { + public string Name { get; set; } + + public LimiterType Type { get; set; } + + public LimiterDiscriminator Discriminator { get; set; } + + public string? DiscriminatorMatch { get; set; } + + public string? DiscriminatorRequestHeaderKey { get; set; } + + public int? MaxRequests { get; set; } + + public int? TimespanMilliseconds { get; set; } + + public RateLimitingAlgorithm? Algorithm { get; set; } + } } \ No newline at end of file diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs index afdd0415..bc451095 100644 --- a/RateLimiter/DependencyInjection/RateLimiterRegister.cs +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -1,8 +1,12 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; + using RateLimiter.Abstractions; +using RateLimiter.Discriminators; using RateLimiter.Middleware; +using System; + namespace RateLimiter.DependencyInjection; public static class RateLimiterRegister @@ -10,11 +14,23 @@ public static class RateLimiterRegister public static IServiceCollection AddRateLimiting(this IServiceCollection services) { // TODO: Need the configuration + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; } + // TODO: Allow consumers to register their own custom Rules (shows extensibility) + public static IServiceCollection AddCustomRule(this IServiceCollection services, T customRule) where T : Type + { + //want + //services.AddKeyedSingleton("RequestPerTimespanRule"); + + //services.AddSingleton(); + //services.AddSingleton(); + return services; + } + public static WebApplication UseRateLimiting(this WebApplication app) { app.UseMiddleware(); diff --git a/RateLimiter/Discriminators/DiscriminatorProvider.cs b/RateLimiter/Discriminators/DiscriminatorProvider.cs new file mode 100644 index 00000000..7227d152 --- /dev/null +++ b/RateLimiter/Discriminators/DiscriminatorProvider.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Http; + +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace RateLimiter.Discriminators +{ + public class DiscriminatorProvider : IProvideDiscriminators + { + public Hashtable GetDiscriminators( + HttpContext context, + IEnumerable rules) + { + var results = new Hashtable(); + + // for each rule in here, we need to generate the discriminator value + foreach (var rule in rules) + { + // TODO: Create discriminator-specific classes for each of these + switch (rule.Discriminator) + { + case LimiterDiscriminator.ApiKey: + results.Add(rule.Name, context.Request.Query["api-key"]); + break; + case LimiterDiscriminator.RequestHeader: + if (string.IsNullOrEmpty(rule.DiscriminatorRequestHeaderKey)) + { + // log + throw new MissingFieldException( + $"{nameof(rule.DiscriminatorRequestHeaderKey)} was not provided"); + } + results.Add(rule.Name, context.Request.Query[rule.DiscriminatorRequestHeaderKey]); + break; + case LimiterDiscriminator.IpAddress: + // TODO: This is likely incorrect. Cannot test b/c shows "localhost" + results.Add(rule.Name, context.Request.Headers.Host); + break; + case LimiterDiscriminator.Custom: + case LimiterDiscriminator.GeoLocation: + case LimiterDiscriminator.IpSubNet: + default: + throw new ArgumentOutOfRangeException(); + } + } + + return results; + } + } +} diff --git a/RateLimiter/Config/LimiterDiscriminator.cs b/RateLimiter/Enums/LimiterDiscriminator.cs similarity index 79% rename from RateLimiter/Config/LimiterDiscriminator.cs rename to RateLimiter/Enums/LimiterDiscriminator.cs index 4efafab2..5bd1d1dd 100644 --- a/RateLimiter/Config/LimiterDiscriminator.cs +++ b/RateLimiter/Enums/LimiterDiscriminator.cs @@ -1,4 +1,4 @@ -namespace RateLimiter.Config; +namespace RateLimiter.Enums; public enum LimiterDiscriminator { diff --git a/RateLimiter/Config/LimiterType.cs b/RateLimiter/Enums/LimiterType.cs similarity index 68% rename from RateLimiter/Config/LimiterType.cs rename to RateLimiter/Enums/LimiterType.cs index e9c899d9..6564a5ca 100644 --- a/RateLimiter/Config/LimiterType.cs +++ b/RateLimiter/Enums/LimiterType.cs @@ -1,4 +1,4 @@ -namespace RateLimiter.Config; +namespace RateLimiter.Enums; public enum LimiterType { diff --git a/RateLimiter/Config/RateLimitingAlgorithm.cs b/RateLimiter/Enums/RateLimitingAlgorithm.cs similarity index 70% rename from RateLimiter/Config/RateLimitingAlgorithm.cs rename to RateLimiter/Enums/RateLimitingAlgorithm.cs index ff198819..b673d7a0 100644 --- a/RateLimiter/Config/RateLimitingAlgorithm.cs +++ b/RateLimiter/Enums/RateLimitingAlgorithm.cs @@ -1,7 +1,8 @@ -namespace RateLimiter.Config; +namespace RateLimiter.Enums; public enum RateLimitingAlgorithm { + Default, TokenBucket, LeakyBucket, FixedWindow, diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs index bfeda612..ec858a9d 100644 --- a/RateLimiter/Middleware/RateLimiterMiddleware.cs +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -34,17 +34,11 @@ public async Task Invoke(HttpContext context) if (rateLimitedResources is not null && rateLimitedResources.Any()) { - // TODO: Do not default to this discriminator - var token = context.Request.Query["clientToken"]; - - var (isAllowed, message) = _rateLimiter.IsRequestAllowed(rateLimitedResources); + var (isAllowed, message) = _rateLimiter.IsRequestAllowed(context, rateLimitedResources); if (!isAllowed) { - // if invalid, or rate-limited: - // log - // return 429 - _logger.LogWarning("Client was rate limited with {@Token}", token); + _logger.LogWarning("Client was rate limited with {@Message}", message); context.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.Response.WriteAsync(message); return; diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index be833b15..947d3fdc 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -1,6 +1,14 @@ -using RateLimiter.Abstractions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using RateLimiter.Abstractions; using RateLimiter.Config; +using RateLimiter.Enums; +using RateLimiter.Rules; +using RateLimiter.Rules.Algorithms; +using System; using System.Collections.Generic; using System.Linq; @@ -8,32 +16,147 @@ namespace RateLimiter; public class RateLimiter : IRateLimitRequests { - private readonly IEnumerable _rules; + private readonly ILogger _logger; + private readonly IDateTimeProvider _dateTimeProvider; + + /// + /// List of rules as defined in appSettings.RateLimiter section + /// + private readonly IEnumerable _rules; + + private readonly IProvideDiscriminators _discriminatorsProvider; + private readonly Dictionary _ruleNameAlgorithm; public RateLimiter( - IProvideRateLimitRules rulesFactory) + ILogger logger, + IDateTimeProvider dateTimeProvider, + IOptions options, + IProvideRateLimitRules rulesFactory, + IProvideDiscriminators discriminatorsProvider) { - _rules = rulesFactory.GetRules(); + _logger = logger; + _dateTimeProvider = dateTimeProvider; + _rules = rulesFactory.GetRules(options.Value); + + // We need to instantiate an instance of an algorithm for each configuration we find + // Why? Even though 2 rules might specify the same algo, the config-based specifics could be different + _ruleNameAlgorithm = GenerateAlgorithmsFromRules(_rules); + + _discriminatorsProvider = discriminatorsProvider; } - public (bool, string) IsRequestAllowed(IEnumerable rateLimitedResources) + public (bool RequestIsAllowed, string ErrorMessage) IsRequestAllowed( + HttpContext context, + IEnumerable rateLimitedResources) { - // need to get the discriminator for each incoming rale limit configuration - var discriminators = GetDiscriminators(); //key: name value: discriminatorValue + // get the matching rules for this request + var matchingRules = _rules.Where(r => rateLimitedResources + .Select(x => x.RuleName) + .ToList().Contains(r.Name)) + .ToList(); - // get the matching rules - var rules = _rules.Where(r => rateLimitedResources.Select(x => x.RuleName) - .ToList().Contains(r.Name)); + if (matchingRules.Count == 0) + { + _logger.LogInformation("No match for {@RuleName}", rateLimitedResources.First().RuleName); + return (true, string.Empty); + } + + // get the algorithm required for each rule to be evaluated (move to ctor?) + var requiredAlgorithms = _ruleNameAlgorithm.Where(x => matchingRules.Select(y => y.Name).Contains(x.Key)) + .Select(x => x.Value); + + // need to get the discriminator for each incoming rate limit configuration + var discriminators = _discriminatorsProvider.GetDiscriminators(context, matchingRules); //key: name value: discriminatorValue + + var passed = true; + foreach (var rule in matchingRules) + { + passed = _ruleNameAlgorithm[rule.Name].IsAllowed(discriminators[rule.Name].ToString()); + if (!passed) + break; + } // ensure they all pass - var passed = rules.All(x => x.IsAllowed(x.Discriminator.ToString())); + //var passed = algorithms.All(x => x.IsAllowed(discriminators[x.Name].ToString())); return passed ? (passed, string.Empty) : (passed, "some message about banging on our door too much"); } - private List<(string, string)> GetDiscriminators() + /// + /// From the rules we have configured in appSettings for our rate limiter, + /// we need to instantiate an algorithm configured to satisfy the rule's configuration + /// + /// + /// + /// + /// + private Dictionary GenerateAlgorithmsFromRules(IEnumerable rules) + { + var values = new Dictionary(); + + var algorithms = new Dictionary(); + + foreach (var rule in rules) + { + switch (rule.Type) + { + case LimiterType.RequestsPerTimespan: + + if (rule is not RequestPerTimespanRule typedRule) + { + throw new InvalidCastException("uh oh"); + } + + // do we have an algorithm that meets these requirements? + var algoKey = $"{typedRule.Algorithm}|{typedRule.MaxRequests}|{typedRule.TimespanMilliseconds}"; + + if (!algorithms.ContainsKey(algoKey)) + { + // create the required algo with the required config + var algo = GetAlgorithm( + _dateTimeProvider, + typedRule.Algorithm, + typedRule.MaxRequests, + typedRule.TimespanMilliseconds); + values.Add(typedRule.Name, algo); + } + else + { + var existingAlgo = algorithms[algoKey]; + values.Add(typedRule.Name, existingAlgo); + } + break; + case LimiterType.TimespanElapsed: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + return values; + } + + private static IRateLimitRuleAlgorithm GetAlgorithm( + IDateTimeProvider dateTimeProvider, + RateLimitingAlgorithm algo, + int? maxRequests, + TimeSpan? timespanMilliseconds) { - return [("foo", "bar)")]; + switch (algo) + { + case RateLimitingAlgorithm.Default: + case RateLimitingAlgorithm.FixedWindow: + return new FixedWindowRule(dateTimeProvider, new FixedWindowRuleConfiguration() + { + MaxRequests = maxRequests.Value, + WindowDuration = timespanMilliseconds.Value + }); + case RateLimitingAlgorithm.TokenBucket: + case RateLimitingAlgorithm.LeakyBucket: + case RateLimitingAlgorithm.SlidingWindow: + default: + throw new ArgumentOutOfRangeException(nameof(algo), algo, null); + } } } \ No newline at end of file diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs index ecf97f65..5674c753 100644 --- a/RateLimiter/RateLimiterRulesFactory.cs +++ b/RateLimiter/RateLimiterRulesFactory.cs @@ -1,16 +1,58 @@ using RateLimiter.Abstractions; +using RateLimiter.Config; +using RateLimiter.Enums; +using RateLimiter.Rules; +using System; using System.Collections.Generic; namespace RateLimiter; public class RateLimiterRulesFactory : IProvideRateLimitRules { - public IEnumerable GetRules() + public IEnumerable GetRules(RateLimiterConfiguration configuration) { - // Load built-in rules + var rules = new List(); // Load rules defined via appSettings - return new List(); + foreach (var rule in configuration.Rules) + { + switch (rule.Type) + { + case LimiterType.RequestsPerTimespan: + rules.Add(new RequestPerTimespanRule() + { + Name = rule.Name, + Algorithm = rule.Algorithm is null or RateLimitingAlgorithm.Default ? configuration.DefaultAlgorithm : rule.Algorithm.Value, + Discriminator = rule.Discriminator, + DiscriminatorMatch = rule.DiscriminatorMatch, + DiscriminatorRequestHeaderKey = rule.DiscriminatorRequestHeaderKey, + MaxRequests = rule.MaxRequests ?? configuration.DefaultMaxRequests, + TimespanMilliseconds = rule.TimespanMilliseconds is null ? + TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : + TimeSpan.FromMilliseconds(rule.TimespanMilliseconds.Value) + }); + break; + case LimiterType.TimespanElapsed: + rules.Add(new TimespanElapsedRule() + { + Name = rule.Name, + Algorithm = rule.Algorithm is null or RateLimitingAlgorithm.Default ? configuration.DefaultAlgorithm : rule.Algorithm.Value, + Discriminator = rule.Discriminator, + DiscriminatorMatch = rule.DiscriminatorMatch, + DiscriminatorRequestHeaderKey = rule.DiscriminatorRequestHeaderKey, + TimespanSinceMilliseconds = rule.TimespanMilliseconds is null ? + TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : + TimeSpan.FromMilliseconds(rule.TimespanMilliseconds.Value) + }); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + // TODO: Load user-defined rules? Not sure if we will do that. User-defined Discriminators, sure - but not a rule ... + + return rules; } } \ No newline at end of file diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/Algorithms/FixedWindowRule.cs similarity index 67% rename from RateLimiter/Rules/FixedWindowRule.cs rename to RateLimiter/Rules/Algorithms/FixedWindowRule.cs index 5be4fcf3..090d73a4 100644 --- a/RateLimiter/Rules/FixedWindowRule.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindowRule.cs @@ -1,27 +1,33 @@ using RateLimiter.Abstractions; +using RateLimiter.Enums; using System; using System.Collections.Concurrent; -using RateLimiter.Config; -namespace RateLimiter.Rules; +namespace RateLimiter.Rules.Algorithms; -public class FixedWindowRule : IRateLimitRule +public class FixedWindowRule : IRateLimitRuleAlgorithm { + private readonly IDateTimeProvider _dateTimeProvider; private readonly int _maxRequests; private readonly TimeSpan _windowDuration; private readonly ConcurrentDictionary _clientWindows; - public FixedWindowRule(FixedWindowRuleConfiguration configuration) + public FixedWindowRule( + IDateTimeProvider dateTimeProvider, + FixedWindowRuleConfiguration configuration) { + _dateTimeProvider = dateTimeProvider; _maxRequests = configuration.MaxRequests; _windowDuration = configuration.WindowDuration; _clientWindows = new ConcurrentDictionary(); } + public string Name { get; set; } = nameof(FixedWindowRule); + public bool IsAllowed(string discriminator) { - var now = DateTime.UtcNow; + var now = _dateTimeProvider.UtcNow(); // Atomically update or create a window for the client var window = _clientWindows.AddOrUpdate( @@ -37,4 +43,6 @@ public bool IsAllowed(string discriminator) } public LimiterDiscriminator Discriminator { get; set; } + + public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.FixedWindow; } \ No newline at end of file diff --git a/RateLimiter/Rules/FixedWindowRuleConfiguration.cs b/RateLimiter/Rules/Algorithms/FixedWindowRuleConfiguration.cs similarity index 92% rename from RateLimiter/Rules/FixedWindowRuleConfiguration.cs rename to RateLimiter/Rules/Algorithms/FixedWindowRuleConfiguration.cs index 8b8dd316..e5518cf2 100644 --- a/RateLimiter/Rules/FixedWindowRuleConfiguration.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindowRuleConfiguration.cs @@ -1,4 +1,4 @@ -using RateLimiter.Config; +using RateLimiter.Enums; using System; diff --git a/RateLimiter/Rules/LeakyBucketRule.cs b/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs similarity index 53% rename from RateLimiter/Rules/LeakyBucketRule.cs rename to RateLimiter/Rules/Algorithms/LeakyBucketRule.cs index 545aeaec..411545df 100644 --- a/RateLimiter/Rules/LeakyBucketRule.cs +++ b/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs @@ -1,16 +1,20 @@ using RateLimiter.Abstractions; +using RateLimiter.Enums; using System; -using RateLimiter.Config; namespace RateLimiter.Rules; -public class LeakyBucketRule : IRateLimitRule +public class LeakyBucketRule : IRateLimitRuleAlgorithm { + public string Name { get; set; } + public bool IsAllowed(string discriminator) { throw new NotImplementedException(); } public LimiterDiscriminator Discriminator { get; set; } + + public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.LeakyBucket; } \ No newline at end of file diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs similarity index 53% rename from RateLimiter/Rules/SlidingWindowRule.cs rename to RateLimiter/Rules/Algorithms/SlidingWindowRule.cs index a5895d2c..eadbce1f 100644 --- a/RateLimiter/Rules/SlidingWindowRule.cs +++ b/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs @@ -1,16 +1,20 @@ using RateLimiter.Abstractions; using System; -using RateLimiter.Config; +using RateLimiter.Enums; namespace RateLimiter.Rules; -public class SlidingWindowRule : IRateLimitRule +public class SlidingWindowRule : IRateLimitRuleAlgorithm { + public string Name { get; set; } + public bool IsAllowed(string discriminator) { throw new NotImplementedException(); } public LimiterDiscriminator Discriminator { get; set; } + + public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.SlidingWindow; } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/TokenBucketRule.cs b/RateLimiter/Rules/Algorithms/TokenBucketRule.cs new file mode 100644 index 00000000..102ac8c5 --- /dev/null +++ b/RateLimiter/Rules/Algorithms/TokenBucketRule.cs @@ -0,0 +1,20 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; + +namespace RateLimiter.Rules.Algorithms; + +public class TokenBucketRule : IRateLimitRuleAlgorithm +{ + public string Name { get; set; } + + public bool IsAllowed(string discriminator) + { + throw new NotImplementedException(); + } + + public LimiterDiscriminator Discriminator { get; set; } + + public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.TokenBucket; +} \ No newline at end of file diff --git a/RateLimiter/Rules/RequestPerTimespanRule.cs b/RateLimiter/Rules/RequestPerTimespanRule.cs new file mode 100644 index 00000000..5f9080d7 --- /dev/null +++ b/RateLimiter/Rules/RequestPerTimespanRule.cs @@ -0,0 +1,25 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; + +namespace RateLimiter.Rules +{ + public class RequestPerTimespanRule : IDefineRateLimitRules + { + public LimiterType Type { get; } = LimiterType.RequestsPerTimespan; + + public string Name { get; set; } + + public LimiterDiscriminator Discriminator { get; set; } + public string? DiscriminatorRequestHeaderKey { get; set; } + + public string? DiscriminatorMatch { get; set; } + + public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.Default; + + public int MaxRequests { get; set; } + + public TimeSpan TimespanMilliseconds { get; set; } + } +} diff --git a/RateLimiter/Rules/TimespanElapsedRule.cs b/RateLimiter/Rules/TimespanElapsedRule.cs new file mode 100644 index 00000000..c46381fe --- /dev/null +++ b/RateLimiter/Rules/TimespanElapsedRule.cs @@ -0,0 +1,24 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; + +namespace RateLimiter.Rules +{ + public class TimespanElapsedRule : IDefineRateLimitRules + { + public LimiterType Type { get; } = LimiterType.TimespanElapsed; + + public string Name { get; set; } + + public LimiterDiscriminator Discriminator { get; set; } + + public string? DiscriminatorRequestHeaderKey { get; set; } + + public string? DiscriminatorMatch { get; set; } + + public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.Default; + + public TimeSpan TimespanSinceMilliseconds { get; set; } + } +} diff --git a/RateLimiter/Rules/TokenBucketRule.cs b/RateLimiter/Rules/TokenBucketRule.cs deleted file mode 100644 index c564c686..00000000 --- a/RateLimiter/Rules/TokenBucketRule.cs +++ /dev/null @@ -1,16 +0,0 @@ -using RateLimiter.Abstractions; - -using System; -using RateLimiter.Config; - -namespace RateLimiter.Rules; - -public class TokenBucketRule : IRateLimitRule -{ - public bool IsAllowed(string discriminator) - { - throw new NotImplementedException(); - } - - public LimiterDiscriminator Discriminator { get; set; } -} \ No newline at end of file From eae21e83fc36d69654c9c7e6280ef7dfd1b38172 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Mon, 10 Feb 2025 08:01:55 -0500 Subject: [PATCH 05/29] extensibility implemented --- .../Controllers/WeatherForecastController.cs | 4 ++-- .../Middleware/GeoTokenDiscriminator.cs | 13 ------------ .../RateLimiting/GeoTokenDiscriminator.cs | 12 +++++++++++ RateLimiter.Tests.Api/Program.cs | 6 +++++- RateLimiter.Tests/RateLimiterTest.cs | 3 ++- RateLimiter.sln.DotSettings | 2 ++ .../Abstractions/IDefineRateLimitRules.cs | 2 ++ .../Abstractions/IRateLimitRuleAlgorithm.cs | 2 -- .../Config/RateLimiterConfiguration.cs | 2 ++ .../RateLimiterRegister.cs | 10 +++++---- .../Discriminators/DiscriminatorProvider.cs | 21 +++++++++++++++++++ RateLimiter/RateLimiterRulesFactory.cs | 2 ++ .../Rules/Algorithms/FixedWindowRule.cs | 2 -- .../Rules/Algorithms/LeakyBucketRule.cs | 4 +--- .../Rules/Algorithms/SlidingWindowRule.cs | 6 ++---- .../Rules/Algorithms/TokenBucketRule.cs | 2 -- RateLimiter/Rules/RequestPerTimespanRule.cs | 2 ++ RateLimiter/Rules/TimespanElapsedRule.cs | 2 ++ 18 files changed, 63 insertions(+), 34 deletions(-) delete mode 100644 RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs create mode 100644 RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs create mode 100644 RateLimiter.sln.DotSettings diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs index 774b8cba..2d2b7421 100644 --- a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -20,8 +20,8 @@ public WeatherForecastController(ILogger logger) _logger = logger; } - //[RateLimitedResource(RuleName = "GeoBased")] - [RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] + [RateLimitedResource(RuleName = "GeoTokenRule-US")] + //[RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] [HttpGet(Name = "GetWeatherForecast")] public IEnumerable Get() { diff --git a/RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs b/RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs deleted file mode 100644 index 8bbec5a0..00000000 --- a/RateLimiter.Tests.Api/Middleware/GeoTokenDiscriminator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using RateLimiter.Abstractions; - -namespace RateLimiter.Tests.Api.Middleware -{ - public class GeoTokenDiscriminator : IProvideADiscriminator - { - public string GetDiscriminator(HttpContext context) - { - // get the token - return context.Request.Query["apiKey"].FirstOrDefault() ?? string.Empty; - } - } -} diff --git a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs new file mode 100644 index 00000000..923e0e13 --- /dev/null +++ b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs @@ -0,0 +1,12 @@ +using RateLimiter.Abstractions; + +namespace RateLimiter.Tests.Api.Middleware.RateLimiting; + +public class GeoTokenDiscriminator : IProvideADiscriminator +{ + public string GetDiscriminator(HttpContext context) + { + // get the token + return context.Request.Headers["x-crexi-token"].FirstOrDefault() ?? string.Empty; + } +} \ No newline at end of file diff --git a/RateLimiter.Tests.Api/Program.cs b/RateLimiter.Tests.Api/Program.cs index e4883b56..eb0f24bf 100644 --- a/RateLimiter.Tests.Api/Program.cs +++ b/RateLimiter.Tests.Api/Program.cs @@ -1,16 +1,20 @@ 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(); +builder.Services.AddRateLimiting() + .WithCustomDiscriminator(); builder.Services.Configure( builder.Configuration.GetSection("RateLimiter")); +//builder.Services.AddKeyedSingleton(nameof(GeoTokenDiscriminator)); + var app = builder.Build(); if (app.Environment.IsDevelopment()) diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index ea167ded..dca9d869 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Threading; + using Xunit; using static RateLimiter.Config.RateLimiterConfiguration; @@ -48,7 +49,7 @@ public void WhenFoo_DoesBar() .Returns(appOptions.Value); mocker.Use(new DateTimeProvider()); - mocker.Use(new DiscriminatorProvider()); + mocker.Use(new DiscriminatorProvider(null)); //// mock the rules as would be defined within appSettings //var rateLimitRules = GenerateRateLimitRules(); diff --git a/RateLimiter.sln.DotSettings b/RateLimiter.sln.DotSettings new file mode 100644 index 00000000..7500a249 --- /dev/null +++ b/RateLimiter.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/RateLimiter/Abstractions/IDefineRateLimitRules.cs b/RateLimiter/Abstractions/IDefineRateLimitRules.cs index fb290e1b..09415d07 100644 --- a/RateLimiter/Abstractions/IDefineRateLimitRules.cs +++ b/RateLimiter/Abstractions/IDefineRateLimitRules.cs @@ -10,6 +10,8 @@ public interface IDefineRateLimitRules LimiterDiscriminator Discriminator { get; set; } + string? CustomDiscriminatorName { get; set; } + string? DiscriminatorRequestHeaderKey { get; set; } string? DiscriminatorMatch { get; set; } diff --git a/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs b/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs index c11eee56..44cd71be 100644 --- a/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs +++ b/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs @@ -8,7 +8,5 @@ public interface IRateLimitRuleAlgorithm bool IsAllowed(string discriminator); - LimiterDiscriminator Discriminator { get; set; } - RateLimitingAlgorithm Algorithm { get; set; } } \ No newline at end of file diff --git a/RateLimiter/Config/RateLimiterConfiguration.cs b/RateLimiter/Config/RateLimiterConfiguration.cs index 9ac436ea..0538de9b 100644 --- a/RateLimiter/Config/RateLimiterConfiguration.cs +++ b/RateLimiter/Config/RateLimiterConfiguration.cs @@ -22,6 +22,8 @@ public class RateLimiterRuleItemConfiguration public LimiterDiscriminator Discriminator { get; set; } + public string? CustomDiscriminatorType { get; set; } + public string? DiscriminatorMatch { get; set; } public string? DiscriminatorRequestHeaderKey { get; set; } diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs index bc451095..2da9b6e6 100644 --- a/RateLimiter/DependencyInjection/RateLimiterRegister.cs +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -2,11 +2,10 @@ using Microsoft.Extensions.DependencyInjection; using RateLimiter.Abstractions; +using RateLimiter.Common; using RateLimiter.Discriminators; using RateLimiter.Middleware; -using System; - namespace RateLimiter.DependencyInjection; public static class RateLimiterRegister @@ -14,16 +13,19 @@ public static class RateLimiterRegister public static IServiceCollection AddRateLimiting(this IServiceCollection services) { // TODO: Need the configuration + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; } - // TODO: Allow consumers to register their own custom Rules (shows extensibility) - public static IServiceCollection AddCustomRule(this IServiceCollection services, T customRule) where T : Type + // TODO: Allow consumers to register their own custom discriminators (shows extensibility) + public static IServiceCollection WithCustomDiscriminator(this IServiceCollection services) + where T : class, IProvideADiscriminator { //want + services.AddKeyedSingleton(typeof(T).Name); //services.AddKeyedSingleton("RequestPerTimespanRule"); //services.AddSingleton(); diff --git a/RateLimiter/Discriminators/DiscriminatorProvider.cs b/RateLimiter/Discriminators/DiscriminatorProvider.cs index 7227d152..2e94331a 100644 --- a/RateLimiter/Discriminators/DiscriminatorProvider.cs +++ b/RateLimiter/Discriminators/DiscriminatorProvider.cs @@ -6,15 +6,25 @@ using System; using System.Collections; using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; namespace RateLimiter.Discriminators { public class DiscriminatorProvider : IProvideDiscriminators { + private readonly IServiceProvider _serviceProvider; + + public DiscriminatorProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + public Hashtable GetDiscriminators( HttpContext context, IEnumerable rules) { + // TODO: These values should likely be cached in the caller + var results = new Hashtable(); // for each rule in here, we need to generate the discriminator value @@ -40,6 +50,17 @@ public Hashtable GetDiscriminators( results.Add(rule.Name, context.Request.Headers.Host); break; case LimiterDiscriminator.Custom: + // hmmm ... need to instantiate the custom discriminator registered and execute it? + if (string.IsNullOrEmpty(rule.CustomDiscriminatorName)) + { + throw new MissingFieldException("No value for {@CustomDiscriminatorName", + nameof(rule.CustomDiscriminatorName)); + } + + var foo = _serviceProvider.GetRequiredKeyedService(rule.CustomDiscriminatorName); + var fooValue = foo.GetDiscriminator(context); + results.Add(rule.Name, fooValue); + break; case LimiterDiscriminator.GeoLocation: case LimiterDiscriminator.IpSubNet: default: diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs index 5674c753..30b6bde0 100644 --- a/RateLimiter/RateLimiterRulesFactory.cs +++ b/RateLimiter/RateLimiterRulesFactory.cs @@ -25,6 +25,7 @@ public IEnumerable GetRules(RateLimiterConfiguration conf Name = rule.Name, Algorithm = rule.Algorithm is null or RateLimitingAlgorithm.Default ? configuration.DefaultAlgorithm : rule.Algorithm.Value, Discriminator = rule.Discriminator, + CustomDiscriminatorName = rule.CustomDiscriminatorType, DiscriminatorMatch = rule.DiscriminatorMatch, DiscriminatorRequestHeaderKey = rule.DiscriminatorRequestHeaderKey, MaxRequests = rule.MaxRequests ?? configuration.DefaultMaxRequests, @@ -39,6 +40,7 @@ public IEnumerable GetRules(RateLimiterConfiguration conf Name = rule.Name, Algorithm = rule.Algorithm is null or RateLimitingAlgorithm.Default ? configuration.DefaultAlgorithm : rule.Algorithm.Value, Discriminator = rule.Discriminator, + CustomDiscriminatorName = rule.CustomDiscriminatorType, DiscriminatorMatch = rule.DiscriminatorMatch, DiscriminatorRequestHeaderKey = rule.DiscriminatorRequestHeaderKey, TimespanSinceMilliseconds = rule.TimespanMilliseconds is null ? diff --git a/RateLimiter/Rules/Algorithms/FixedWindowRule.cs b/RateLimiter/Rules/Algorithms/FixedWindowRule.cs index 090d73a4..68882f51 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindowRule.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindowRule.cs @@ -42,7 +42,5 @@ public bool IsAllowed(string discriminator) return window.Count <= _maxRequests; } - public LimiterDiscriminator Discriminator { get; set; } - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.FixedWindow; } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs b/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs index 411545df..9098787a 100644 --- a/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs +++ b/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs @@ -3,7 +3,7 @@ using System; -namespace RateLimiter.Rules; +namespace RateLimiter.Rules.Algorithms; public class LeakyBucketRule : IRateLimitRuleAlgorithm { @@ -14,7 +14,5 @@ public bool IsAllowed(string discriminator) throw new NotImplementedException(); } - public LimiterDiscriminator Discriminator { get; set; } - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.LeakyBucket; } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs b/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs index eadbce1f..1248801a 100644 --- a/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs +++ b/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs @@ -1,9 +1,9 @@ using RateLimiter.Abstractions; +using RateLimiter.Enums; using System; -using RateLimiter.Enums; -namespace RateLimiter.Rules; +namespace RateLimiter.Rules.Algorithms; public class SlidingWindowRule : IRateLimitRuleAlgorithm { @@ -14,7 +14,5 @@ public bool IsAllowed(string discriminator) throw new NotImplementedException(); } - public LimiterDiscriminator Discriminator { get; set; } - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.SlidingWindow; } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/TokenBucketRule.cs b/RateLimiter/Rules/Algorithms/TokenBucketRule.cs index 102ac8c5..a92f957a 100644 --- a/RateLimiter/Rules/Algorithms/TokenBucketRule.cs +++ b/RateLimiter/Rules/Algorithms/TokenBucketRule.cs @@ -14,7 +14,5 @@ public bool IsAllowed(string discriminator) throw new NotImplementedException(); } - public LimiterDiscriminator Discriminator { get; set; } - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.TokenBucket; } \ No newline at end of file diff --git a/RateLimiter/Rules/RequestPerTimespanRule.cs b/RateLimiter/Rules/RequestPerTimespanRule.cs index 5f9080d7..d60c10b5 100644 --- a/RateLimiter/Rules/RequestPerTimespanRule.cs +++ b/RateLimiter/Rules/RequestPerTimespanRule.cs @@ -12,6 +12,8 @@ public class RequestPerTimespanRule : IDefineRateLimitRules public string Name { get; set; } public LimiterDiscriminator Discriminator { get; set; } + public string? CustomDiscriminatorName { get; set; } + public string? DiscriminatorRequestHeaderKey { get; set; } public string? DiscriminatorMatch { get; set; } diff --git a/RateLimiter/Rules/TimespanElapsedRule.cs b/RateLimiter/Rules/TimespanElapsedRule.cs index c46381fe..75f254a0 100644 --- a/RateLimiter/Rules/TimespanElapsedRule.cs +++ b/RateLimiter/Rules/TimespanElapsedRule.cs @@ -13,6 +13,8 @@ public class TimespanElapsedRule : IDefineRateLimitRules public LimiterDiscriminator Discriminator { get; set; } + public string? CustomDiscriminatorName { get; set; } + public string? DiscriminatorRequestHeaderKey { get; set; } public string? DiscriminatorMatch { get; set; } From 04cbe059ac8f1c0506c40060481b5c349bc6ced0 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Mon, 10 Feb 2025 18:04:41 -0500 Subject: [PATCH 06/29] docs, test, housekeeping, and expansion --- RateLimiter.Tests.Api.Minimal/Program.cs | 27 ++--- .../RateLimiting/GeoTokenDiscriminator.cs | 3 +- RateLimiter.Tests.Api/Program.cs | 9 +- RateLimiter.Tests.Api/appsettings.json | 2 +- RateLimiter.Tests/RateLimiterTest.cs | 6 +- .../Rules/Algorithms/FixedWindowTests.cs | 12 +++ .../Rules/Algorithms/LeakyBucketTests.cs | 46 ++++++++ .../Rules/Algorithms/SlidingWindowTests.cs | 24 +++++ .../Rules/Algorithms/TokenBucketTests.cs | 12 +++ .../Rules/FixedWindowRuleTests.cs | 7 +- RateLimiter.sln | 1 + .../Abstractions/IAmARateLimitAlgorithm.cs | 12 +++ ...LimitRules.cs => IDefineARateLimitRule.cs} | 2 +- .../Abstractions/IProvideADiscriminator.cs | 2 +- ...tors.cs => IProvideDiscriminatorValues.cs} | 6 +- .../Abstractions/IProvideRateLimitRules.cs | 2 +- .../Abstractions/IRateLimitRuleAlgorithm.cs | 12 --- .../RateLimiterRegister.cs | 27 +++-- .../Discriminators/DiscriminatorProvider.cs | 34 +++--- .../Discriminators/GeoBasedDiscriminator.cs | 2 +- .../Discriminators/IpAddressDiscriminator.cs | 7 +- ...minator.cs => QueryStringDiscriminator.cs} | 4 +- RateLimiter/Discriminators/Readme.md | 1 + RateLimiter/Enums/LimiterDiscriminator.cs | 2 +- RateLimiter/Enums/RateLimitingAlgorithm.cs | 6 +- RateLimiter/RateLimiter.cs | 54 +++++----- RateLimiter/RateLimiterRulesFactory.cs | 4 +- .../{FixedWindowRule.cs => FixedWindow.cs} | 10 +- ...uration.cs => FixedWindowConfiguration.cs} | 2 +- RateLimiter/Rules/Algorithms/LeakyBucket.cs | 64 +++++++++++ .../Algorithms/LeakyBucketConfiguration.cs | 11 ++ .../Rules/Algorithms/LeakyBucketRule.cs | 18 ---- RateLimiter/Rules/Algorithms/SlidingWindow.cs | 47 ++++++++ .../Rules/Algorithms/SlidingWindowRule.cs | 18 ---- RateLimiter/Rules/Algorithms/TokenBucket.cs | 51 +++++++++ .../Rules/Algorithms/TokenBucketRule.cs | 18 ---- RateLimiter/Rules/RequestPerTimespanRule.cs | 2 +- RateLimiter/Rules/TimespanElapsedRule.cs | 2 +- submission.md | 100 ++++++++++++++++++ 39 files changed, 499 insertions(+), 170 deletions(-) create mode 100644 RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs create mode 100644 RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs create mode 100644 RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs create mode 100644 RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs create mode 100644 RateLimiter/Abstractions/IAmARateLimitAlgorithm.cs rename RateLimiter/Abstractions/{IDefineRateLimitRules.cs => IDefineARateLimitRule.cs} (90%) rename RateLimiter/Abstractions/{IProvideDiscriminators.cs => IProvideDiscriminatorValues.cs} (55%) delete mode 100644 RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs rename RateLimiter/Discriminators/{ApiKeyDiscriminator.cs => QueryStringDiscriminator.cs} (55%) create mode 100644 RateLimiter/Discriminators/Readme.md rename RateLimiter/Rules/Algorithms/{FixedWindowRule.cs => FixedWindow.cs} (81%) rename RateLimiter/Rules/Algorithms/{FixedWindowRuleConfiguration.cs => FixedWindowConfiguration.cs} (88%) create mode 100644 RateLimiter/Rules/Algorithms/LeakyBucket.cs create mode 100644 RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs delete mode 100644 RateLimiter/Rules/Algorithms/LeakyBucketRule.cs create mode 100644 RateLimiter/Rules/Algorithms/SlidingWindow.cs delete mode 100644 RateLimiter/Rules/Algorithms/SlidingWindowRule.cs create mode 100644 RateLimiter/Rules/Algorithms/TokenBucket.cs delete mode 100644 RateLimiter/Rules/Algorithms/TokenBucketRule.cs create mode 100644 submission.md diff --git a/RateLimiter.Tests.Api.Minimal/Program.cs b/RateLimiter.Tests.Api.Minimal/Program.cs index b4e35f3f..07e9fc7e 100644 --- a/RateLimiter.Tests.Api.Minimal/Program.cs +++ b/RateLimiter.Tests.Api.Minimal/Program.cs @@ -22,19 +22,20 @@ }; app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithRateLimiting() -.WithName("GetWeatherForecast"); + { + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; + }) + .WithName("GetWeatherForecast") + .WithRateLimitingRule("MyFirstDistinctRuleName") + .WithRateLimitingRule("MySecondDistinctRuleName"); app.Run(); diff --git a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs index 923e0e13..395e9c1d 100644 --- a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs +++ b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs @@ -4,9 +4,8 @@ namespace RateLimiter.Tests.Api.Middleware.RateLimiting; public class GeoTokenDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context) + public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { - // get the token return context.Request.Headers["x-crexi-token"].FirstOrDefault() ?? string.Empty; } } \ No newline at end of file diff --git a/RateLimiter.Tests.Api/Program.cs b/RateLimiter.Tests.Api/Program.cs index eb0f24bf..4a889cd2 100644 --- a/RateLimiter.Tests.Api/Program.cs +++ b/RateLimiter.Tests.Api/Program.cs @@ -7,13 +7,10 @@ builder.Services.AddControllers(); builder.Services.AddOpenApi(); builder.Services.AddSwaggerGen(); -builder.Services.AddRateLimiting() - .WithCustomDiscriminator(); - -builder.Services.Configure( - builder.Configuration.GetSection("RateLimiter")); -//builder.Services.AddKeyedSingleton(nameof(GeoTokenDiscriminator)); +builder.Services.AddRateLimiting() + .WithCustomDiscriminator() + .WithConfiguration(builder.Configuration.GetSection("RateLimiter")); var app = builder.Build(); diff --git a/RateLimiter.Tests.Api/appsettings.json b/RateLimiter.Tests.Api/appsettings.json index 10782c9d..c3575e25 100644 --- a/RateLimiter.Tests.Api/appsettings.json +++ b/RateLimiter.Tests.Api/appsettings.json @@ -43,7 +43,7 @@ "DiscriminatorMatch": "US", "CustomDiscriminatorType": "GeoTokenDiscriminator", "MaxRequests": 3, - "TimespanMilliseconds": 1000, + "TimespanMilliseconds": 5000, "Algorithm": "Default" }, { diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index dca9d869..d19a44ea 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -49,7 +49,7 @@ public void WhenFoo_DoesBar() .Returns(appOptions.Value); mocker.Use(new DateTimeProvider()); - mocker.Use(new DiscriminatorProvider(null)); + mocker.Use(new DiscriminatorProvider(null, null)); //// mock the rules as would be defined within appSettings //var rateLimitRules = GenerateRateLimitRules(); @@ -123,8 +123,8 @@ private static List GenerateRateLimitRules() fixture.Build() .With(x => x.Name, "ApiKey-Default") .With(x => x.Type, LimiterType.RequestsPerTimespan) - .With(x => x.Discriminator, LimiterDiscriminator.ApiKey) - .With(x => x.DiscriminatorMatch, string.Empty) + .With(x => x.Discriminator, LimiterDiscriminator.QueryString) + .With(x => x.DiscriminatorMatch, "x-crexi-token") .With(x => x.DiscriminatorRequestHeaderKey, string.Empty) .With(x => x.Algorithm, RateLimitingAlgorithm.Default) .With(x => x.TimespanMilliseconds, 4000) diff --git a/RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs b/RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs new file mode 100644 index 00000000..ac7ebec8 --- /dev/null +++ b/RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Tests.Rules.Algorithms +{ + internal class FixedWindowTests + { + } +} diff --git a/RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs b/RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs new file mode 100644 index 00000000..07558569 --- /dev/null +++ b/RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs @@ -0,0 +1,46 @@ +using AutoFixture; + +using Moq.AutoMock; + +using RateLimiter.Abstractions; +using RateLimiter.Common; +using RateLimiter.Rules.Algorithms; + +using Xunit; + +namespace RateLimiter.Tests.Rules.Algorithms +{ + public class LeakyBucketTests + { + [Fact] + public void LeakyBucket_ProcessesRequestsAtSteadyRate() + { + var mocker = new AutoMocker(); + var fixture = new Fixture(); + + // arrange + mocker.Use(new DateTimeProvider()); + + var rule = mocker.CreateInstance(); + // act + + // assert + + //var rule = new LeakyBucket(5, TimeSpan.FromSeconds(1), clock); + + // Fill the bucket to capacity (5 requests) + //for (int i = 0; i < 5; i++) + //{ + // Assert.IsTrue(rule.IsAllowed("client1")); // ✅ + //} + //Assert.IsFalse(rule.IsAllowed("client1")); // ❌ (Bucket full) + + //// Wait 3 seconds (3 requests leak out) + //clock.Advance(TimeSpan.FromSeconds(3)); + //Assert.IsTrue(rule.IsAllowed("client1")); // ✅ (Count: 5 - 3 + 1 = 3) + //Assert.IsTrue(rule.IsAllowed("client1")); // ✅ (Count: 4) + //Assert.IsTrue(rule.IsAllowed("client1")); // ✅ (Count: 5) + //Assert.IsFalse(rule.IsAllowed("client1")); // ❌ (Bucket full again) + } + } +} diff --git a/RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs b/RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs new file mode 100644 index 00000000..c3bded71 --- /dev/null +++ b/RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs @@ -0,0 +1,24 @@ +using AutoFixture; + +using Moq.AutoMock; + +using Xunit; + +namespace RateLimiter.Tests.Rules.Algorithms +{ + public class SlidingWindowTests + { + [Fact] + public void WhenFoo_DoesBar() + { + var mocker = new AutoMocker(); + var fixture = new Fixture(); + + // arrange + + // act + + // assert + } + } +} diff --git a/RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs b/RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs new file mode 100644 index 00000000..7b49b2fe --- /dev/null +++ b/RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Tests.Rules.Algorithms +{ + internal class TokenBucketTests + { + } +} diff --git a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs index 2d6381e6..5cb2d0d4 100644 --- a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs +++ b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs @@ -1,11 +1,12 @@ using FluentAssertions; +using RateLimiter.Common; using RateLimiter.Rules; using RateLimiter.Rules.Algorithms; using System; using System.Threading; -using RateLimiter.Common; + using Xunit; namespace RateLimiter.Tests.Rules @@ -16,9 +17,9 @@ public class FixedWindowRuleTests public void WhenFoo_DoesBar() { // TODO: Turn this into a theory and attempt to handle edge cases - var rule = new FixedWindowRule( + var rule = new FixedWindow( new DateTimeProvider(), - new FixedWindowRuleConfiguration() + new FixedWindowConfiguration() { MaxRequests = 3, WindowDuration = TimeSpan.FromSeconds(3) diff --git a/RateLimiter.sln b/RateLimiter.sln index 4d3d5a34..b6defdd6 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -12,6 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution dev-notes.md = dev-notes.md overview.mermaid = overview.mermaid README.md = README.md + submission.md = submission.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EFA099B0-7DF4-40D2-8CAA-92730F7E25BF}" diff --git a/RateLimiter/Abstractions/IAmARateLimitAlgorithm.cs b/RateLimiter/Abstractions/IAmARateLimitAlgorithm.cs new file mode 100644 index 00000000..6cf294aa --- /dev/null +++ b/RateLimiter/Abstractions/IAmARateLimitAlgorithm.cs @@ -0,0 +1,12 @@ +using RateLimiter.Enums; + +namespace RateLimiter.Abstractions; + +public interface IAmARateLimitAlgorithm +{ + string Name { get; init; } + + bool IsAllowed(string discriminator); + + RateLimitingAlgorithm Algorithm { get; init; } +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IDefineRateLimitRules.cs b/RateLimiter/Abstractions/IDefineARateLimitRule.cs similarity index 90% rename from RateLimiter/Abstractions/IDefineRateLimitRules.cs rename to RateLimiter/Abstractions/IDefineARateLimitRule.cs index 09415d07..4d4f21c3 100644 --- a/RateLimiter/Abstractions/IDefineRateLimitRules.cs +++ b/RateLimiter/Abstractions/IDefineARateLimitRule.cs @@ -2,7 +2,7 @@ namespace RateLimiter.Abstractions; -public interface IDefineRateLimitRules +public interface IDefineARateLimitRule { LimiterType Type { get; } diff --git a/RateLimiter/Abstractions/IProvideADiscriminator.cs b/RateLimiter/Abstractions/IProvideADiscriminator.cs index 9bd45279..c9d13f74 100644 --- a/RateLimiter/Abstractions/IProvideADiscriminator.cs +++ b/RateLimiter/Abstractions/IProvideADiscriminator.cs @@ -4,6 +4,6 @@ namespace RateLimiter.Abstractions { public interface IProvideADiscriminator { - string GetDiscriminator(HttpContext context); + string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule); } } diff --git a/RateLimiter/Abstractions/IProvideDiscriminators.cs b/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs similarity index 55% rename from RateLimiter/Abstractions/IProvideDiscriminators.cs rename to RateLimiter/Abstractions/IProvideDiscriminatorValues.cs index c6572a78..38ace2c9 100644 --- a/RateLimiter/Abstractions/IProvideDiscriminators.cs +++ b/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs @@ -4,9 +4,9 @@ namespace RateLimiter.Abstractions; -public interface IProvideDiscriminators +public interface IProvideDiscriminatorValues { - Hashtable GetDiscriminators( + Hashtable GetDiscriminatorValues( HttpContext context, - IEnumerable rules); + IEnumerable rules); } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IProvideRateLimitRules.cs b/RateLimiter/Abstractions/IProvideRateLimitRules.cs index 10f9ddbe..fc579c11 100644 --- a/RateLimiter/Abstractions/IProvideRateLimitRules.cs +++ b/RateLimiter/Abstractions/IProvideRateLimitRules.cs @@ -6,5 +6,5 @@ namespace RateLimiter.Abstractions; public interface IProvideRateLimitRules { - IEnumerable GetRules(RateLimiterConfiguration config); + IEnumerable GetRules(RateLimiterConfiguration config); } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs b/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs deleted file mode 100644 index 44cd71be..00000000 --- a/RateLimiter/Abstractions/IRateLimitRuleAlgorithm.cs +++ /dev/null @@ -1,12 +0,0 @@ -using RateLimiter.Enums; - -namespace RateLimiter.Abstractions; - -public interface IRateLimitRuleAlgorithm -{ - string Name { get; set; } - - bool IsAllowed(string discriminator); - - RateLimitingAlgorithm Algorithm { get; set; } -} \ No newline at end of file diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs index 2da9b6e6..89e2e647 100644 --- a/RateLimiter/DependencyInjection/RateLimiterRegister.cs +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using RateLimiter.Abstractions; using RateLimiter.Common; +using RateLimiter.Config; using RateLimiter.Discriminators; using RateLimiter.Middleware; @@ -12,24 +14,31 @@ public static class RateLimiterRegister { public static IServiceCollection AddRateLimiting(this IServiceCollection services) { - // TODO: Need the configuration services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; } - - // TODO: Allow consumers to register their own custom discriminators (shows extensibility) + + /// + /// Allow consumers to register their own custom discriminators (shows extensibility) + /// + /// + /// + /// public static IServiceCollection WithCustomDiscriminator(this IServiceCollection services) where T : class, IProvideADiscriminator { - //want services.AddKeyedSingleton(typeof(T).Name); - //services.AddKeyedSingleton("RequestPerTimespanRule"); + return services; + } - //services.AddSingleton(); - //services.AddSingleton(); + public static IServiceCollection WithConfiguration( + this IServiceCollection services, + IConfigurationSection section) + { + services.Configure(section); return services; } @@ -39,7 +48,7 @@ public static WebApplication UseRateLimiting(this WebApplication app) return app; } - public static RouteHandlerBuilder WithRateLimiting(this RouteHandlerBuilder builder) + public static RouteHandlerBuilder WithRateLimitingRule(this RouteHandlerBuilder builder, string ruleName) { // TODO: Implement return builder; diff --git a/RateLimiter/Discriminators/DiscriminatorProvider.cs b/RateLimiter/Discriminators/DiscriminatorProvider.cs index 2e94331a..caee1239 100644 --- a/RateLimiter/Discriminators/DiscriminatorProvider.cs +++ b/RateLimiter/Discriminators/DiscriminatorProvider.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using RateLimiter.Abstractions; using RateLimiter.Enums; @@ -6,22 +8,25 @@ using System; using System.Collections; using System.Collections.Generic; -using Microsoft.Extensions.DependencyInjection; namespace RateLimiter.Discriminators { - public class DiscriminatorProvider : IProvideDiscriminators + public class DiscriminatorProvider : IProvideDiscriminatorValues { + private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; - public DiscriminatorProvider(IServiceProvider serviceProvider) + public DiscriminatorProvider( + ILogger logger, + IServiceProvider serviceProvider) { + _logger = logger; _serviceProvider = serviceProvider; } - public Hashtable GetDiscriminators( + public Hashtable GetDiscriminatorValues( HttpContext context, - IEnumerable rules) + IEnumerable rules) { // TODO: These values should likely be cached in the caller @@ -30,24 +35,25 @@ public Hashtable GetDiscriminators( // for each rule in here, we need to generate the discriminator value foreach (var rule in rules) { - // TODO: Create discriminator-specific classes for each of these switch (rule.Discriminator) { - case LimiterDiscriminator.ApiKey: - results.Add(rule.Name, context.Request.Query["api-key"]); + case LimiterDiscriminator.QueryString: + var qsd = new QueryStringDiscriminator(); + var qsdResult = qsd.GetDiscriminator(context, rule); + results.Add(rule.Name, qsdResult); break; case LimiterDiscriminator.RequestHeader: if (string.IsNullOrEmpty(rule.DiscriminatorRequestHeaderKey)) { - // log - throw new MissingFieldException( - $"{nameof(rule.DiscriminatorRequestHeaderKey)} was not provided"); + // TODO: Log + throw new MissingFieldException($"{nameof(rule.DiscriminatorRequestHeaderKey)} was not provided"); } results.Add(rule.Name, context.Request.Query[rule.DiscriminatorRequestHeaderKey]); break; case LimiterDiscriminator.IpAddress: - // TODO: This is likely incorrect. Cannot test b/c shows "localhost" - results.Add(rule.Name, context.Request.Headers.Host); + var ipad = new IpAddressDiscriminator(); + var ipadResult = ipad.GetDiscriminator(context, rule); + results.Add(rule.Name, ipadResult); break; case LimiterDiscriminator.Custom: // hmmm ... need to instantiate the custom discriminator registered and execute it? @@ -58,7 +64,7 @@ public Hashtable GetDiscriminators( } var foo = _serviceProvider.GetRequiredKeyedService(rule.CustomDiscriminatorName); - var fooValue = foo.GetDiscriminator(context); + var fooValue = foo.GetDiscriminator(context, rule); results.Add(rule.Name, fooValue); break; case LimiterDiscriminator.GeoLocation: diff --git a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs index 77238e18..7dfa2075 100644 --- a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs +++ b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs @@ -6,7 +6,7 @@ namespace RateLimiter.Discriminators { public class GeoBasedDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context) + public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { // get the ip address via cache/external source diff --git a/RateLimiter/Discriminators/IpAddressDiscriminator.cs b/RateLimiter/Discriminators/IpAddressDiscriminator.cs index 67f6dc76..e516a134 100644 --- a/RateLimiter/Discriminators/IpAddressDiscriminator.cs +++ b/RateLimiter/Discriminators/IpAddressDiscriminator.cs @@ -2,15 +2,14 @@ using RateLimiter.Abstractions; -using System; - namespace RateLimiter.Discriminators { public class IpAddressDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context) + public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { - throw new NotImplementedException(); + // TODO: This is likely incorrect. Cannot test b/c shows "localhost" + return context.Request.Headers.Host.ToString(); } } } diff --git a/RateLimiter/Discriminators/ApiKeyDiscriminator.cs b/RateLimiter/Discriminators/QueryStringDiscriminator.cs similarity index 55% rename from RateLimiter/Discriminators/ApiKeyDiscriminator.cs rename to RateLimiter/Discriminators/QueryStringDiscriminator.cs index 53bcacdf..b5183616 100644 --- a/RateLimiter/Discriminators/ApiKeyDiscriminator.cs +++ b/RateLimiter/Discriminators/QueryStringDiscriminator.cs @@ -6,9 +6,9 @@ namespace RateLimiter.Discriminators { - public class ApiKeyDiscriminator : IProvideADiscriminator + public class QueryStringDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context) + public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { throw new NotImplementedException(); } diff --git a/RateLimiter/Discriminators/Readme.md b/RateLimiter/Discriminators/Readme.md new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/RateLimiter/Discriminators/Readme.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/RateLimiter/Enums/LimiterDiscriminator.cs b/RateLimiter/Enums/LimiterDiscriminator.cs index 5bd1d1dd..dea1b68c 100644 --- a/RateLimiter/Enums/LimiterDiscriminator.cs +++ b/RateLimiter/Enums/LimiterDiscriminator.cs @@ -2,10 +2,10 @@ public enum LimiterDiscriminator { - ApiKey, Custom, GeoLocation, IpAddress, IpSubNet, + QueryString, RequestHeader } \ No newline at end of file diff --git a/RateLimiter/Enums/RateLimitingAlgorithm.cs b/RateLimiter/Enums/RateLimitingAlgorithm.cs index b673d7a0..a8919193 100644 --- a/RateLimiter/Enums/RateLimitingAlgorithm.cs +++ b/RateLimiter/Enums/RateLimitingAlgorithm.cs @@ -3,8 +3,8 @@ public enum RateLimitingAlgorithm { Default, - TokenBucket, - LeakyBucket, FixedWindow, - SlidingWindow + LeakyBucket, + SlidingWindow, + TokenBucket } \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 947d3fdc..820933c5 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -20,19 +20,19 @@ public class RateLimiter : IRateLimitRequests private readonly IDateTimeProvider _dateTimeProvider; /// - /// List of rules as defined in appSettings.RateLimiter section + /// List of rules as defined in appSettings.RateLimiter section (or via Fluent registration) /// - private readonly IEnumerable _rules; + private readonly IEnumerable _rules; - private readonly IProvideDiscriminators _discriminatorsProvider; - private readonly Dictionary _ruleNameAlgorithm; + private readonly IProvideDiscriminatorValues _discriminatorsProvider; + private readonly Dictionary _ruleNameAlgorithm; public RateLimiter( ILogger logger, IDateTimeProvider dateTimeProvider, IOptions options, IProvideRateLimitRules rulesFactory, - IProvideDiscriminators discriminatorsProvider) + IProvideDiscriminatorValues discriminatorsProvider) { _logger = logger; _dateTimeProvider = dateTimeProvider; @@ -62,16 +62,20 @@ public RateLimiter( } // get the algorithm required for each rule to be evaluated (move to ctor?) - var requiredAlgorithms = _ruleNameAlgorithm.Where(x => matchingRules.Select(y => y.Name).Contains(x.Key)) - .Select(x => x.Value); + //var requiredAlgorithms = _ruleNameAlgorithm + // .Where(x => matchingRules.Select(y => y.Name) + // .Contains(x.Key)) + // .Select(x => x.Value); // need to get the discriminator for each incoming rate limit configuration - var discriminators = _discriminatorsProvider.GetDiscriminators(context, matchingRules); //key: name value: discriminatorValue + var discriminatorValues = _discriminatorsProvider + .GetDiscriminatorValues(context, matchingRules); //key: name value: discriminatorValue + // TODO: Make this a single call (no iterations) var passed = true; foreach (var rule in matchingRules) { - passed = _ruleNameAlgorithm[rule.Name].IsAllowed(discriminators[rule.Name].ToString()); + passed = _ruleNameAlgorithm[rule.Name].IsAllowed(discriminatorValues[rule.Name].ToString()); if (!passed) break; } @@ -91,11 +95,11 @@ public RateLimiter( /// /// /// - private Dictionary GenerateAlgorithmsFromRules(IEnumerable rules) + private Dictionary GenerateAlgorithmsFromRules(IEnumerable rules) { - var values = new Dictionary(); + var values = new Dictionary(); - var algorithms = new Dictionary(); + var algorithms = new Dictionary(); foreach (var rule in rules) { @@ -111,7 +115,7 @@ private Dictionary GenerateAlgorithmsFromRules( // do we have an algorithm that meets these requirements? var algoKey = $"{typedRule.Algorithm}|{typedRule.MaxRequests}|{typedRule.TimespanMilliseconds}"; - if (!algorithms.ContainsKey(algoKey)) + if (!algorithms.TryGetValue(algoKey, out var existingAlgo)) { // create the required algo with the required config var algo = GetAlgorithm( @@ -120,10 +124,10 @@ private Dictionary GenerateAlgorithmsFromRules( typedRule.MaxRequests, typedRule.TimespanMilliseconds); values.Add(typedRule.Name, algo); + algorithms.Add(algoKey, algo); } else { - var existingAlgo = algorithms[algoKey]; values.Add(typedRule.Name, existingAlgo); } break; @@ -137,26 +141,24 @@ private Dictionary GenerateAlgorithmsFromRules( return values; } - private static IRateLimitRuleAlgorithm GetAlgorithm( + private static IAmARateLimitAlgorithm GetAlgorithm( IDateTimeProvider dateTimeProvider, RateLimitingAlgorithm algo, int? maxRequests, TimeSpan? timespanMilliseconds) { - switch (algo) + return algo switch { - case RateLimitingAlgorithm.Default: - case RateLimitingAlgorithm.FixedWindow: - return new FixedWindowRule(dateTimeProvider, new FixedWindowRuleConfiguration() + RateLimitingAlgorithm.Default or RateLimitingAlgorithm.FixedWindow => new FixedWindow(dateTimeProvider, + new FixedWindowConfiguration() { MaxRequests = maxRequests.Value, WindowDuration = timespanMilliseconds.Value - }); - case RateLimitingAlgorithm.TokenBucket: - case RateLimitingAlgorithm.LeakyBucket: - case RateLimitingAlgorithm.SlidingWindow: - default: - throw new ArgumentOutOfRangeException(nameof(algo), algo, null); - } + }), + RateLimitingAlgorithm.TokenBucket => new TokenBucket(), + RateLimitingAlgorithm.LeakyBucket => new LeakyBucket(dateTimeProvider, maxRequests.Value, timespanMilliseconds.Value), + RateLimitingAlgorithm.SlidingWindow => new SlidingWindow(dateTimeProvider, maxRequests.Value, timespanMilliseconds.Value), + _ => throw new ArgumentOutOfRangeException(nameof(algo), algo, null) + }; } } \ No newline at end of file diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs index 30b6bde0..88e791f2 100644 --- a/RateLimiter/RateLimiterRulesFactory.cs +++ b/RateLimiter/RateLimiterRulesFactory.cs @@ -10,9 +10,9 @@ namespace RateLimiter; public class RateLimiterRulesFactory : IProvideRateLimitRules { - public IEnumerable GetRules(RateLimiterConfiguration configuration) + public IEnumerable GetRules(RateLimiterConfiguration configuration) { - var rules = new List(); + var rules = new List(); // Load rules defined via appSettings foreach (var rule in configuration.Rules) diff --git a/RateLimiter/Rules/Algorithms/FixedWindowRule.cs b/RateLimiter/Rules/Algorithms/FixedWindow.cs similarity index 81% rename from RateLimiter/Rules/Algorithms/FixedWindowRule.cs rename to RateLimiter/Rules/Algorithms/FixedWindow.cs index 68882f51..6e4c91b0 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindowRule.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindow.cs @@ -6,16 +6,16 @@ namespace RateLimiter.Rules.Algorithms; -public class FixedWindowRule : IRateLimitRuleAlgorithm +public class FixedWindow : IAmARateLimitAlgorithm { private readonly IDateTimeProvider _dateTimeProvider; private readonly int _maxRequests; private readonly TimeSpan _windowDuration; private readonly ConcurrentDictionary _clientWindows; - public FixedWindowRule( + public FixedWindow( IDateTimeProvider dateTimeProvider, - FixedWindowRuleConfiguration configuration) + FixedWindowConfiguration configuration) { _dateTimeProvider = dateTimeProvider; _maxRequests = configuration.MaxRequests; @@ -23,7 +23,7 @@ public FixedWindowRule( _clientWindows = new ConcurrentDictionary(); } - public string Name { get; set; } = nameof(FixedWindowRule); + public string Name { get; init; } = nameof(FixedWindow); public bool IsAllowed(string discriminator) { @@ -42,5 +42,5 @@ public bool IsAllowed(string discriminator) return window.Count <= _maxRequests; } - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.FixedWindow; + public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.FixedWindow; } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/FixedWindowRuleConfiguration.cs b/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs similarity index 88% rename from RateLimiter/Rules/Algorithms/FixedWindowRuleConfiguration.cs rename to RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs index e5518cf2..fa723778 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindowRuleConfiguration.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs @@ -4,7 +4,7 @@ namespace RateLimiter.Rules; -public record FixedWindowRuleConfiguration +public record FixedWindowConfiguration { public string Name { get; set; } diff --git a/RateLimiter/Rules/Algorithms/LeakyBucket.cs b/RateLimiter/Rules/Algorithms/LeakyBucket.cs new file mode 100644 index 00000000..948c02fa --- /dev/null +++ b/RateLimiter/Rules/Algorithms/LeakyBucket.cs @@ -0,0 +1,64 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules.Algorithms; + +public class LeakyBucket : IAmARateLimitAlgorithm +{ + private readonly int _capacity; + private readonly TimeSpan _leakInterval; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly ConcurrentDictionary _buckets; + + public string Name { get; init; } = nameof(LeakyBucket); + + public LeakyBucket( + IDateTimeProvider dateTimeProvider, + LeakyBucketConfiguration configuration) + { + _dateTimeProvider = dateTimeProvider; + _capacity = configuration.Capacity; + _leakInterval = configuration.Interval; + } + + public bool IsAllowed(string discriminator) + { + var bucket = _buckets.GetOrAdd(discriminator, _ => new BucketState()); + + lock (bucket.Lock) + { + var now = _dateTimeProvider.UtcNow(); + var timeElapsed = now - bucket.LastLeakTime; + + // Calculate how many requests have "leaked out" since the last check + var leakedRequests = (int)(timeElapsed.Ticks / _leakInterval.Ticks); + + if (leakedRequests > 0) + { + bucket.CurrentCount = Math.Max(0, bucket.CurrentCount - leakedRequests); + // Adjust last leak time to account for partial intervals + bucket.LastLeakTime = now.AddTicks(-(timeElapsed.Ticks % _leakInterval.Ticks)); + } + + // Allow request if bucket isn't full + if (bucket.CurrentCount >= _capacity) + return false; + + bucket.CurrentCount++; + + return true; + } + } + + public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.LeakyBucket; + + private class BucketState + { + public int CurrentCount { get; set; } + public DateTime LastLeakTime { get; set; } = DateTime.MinValue; + public object Lock { get; } = new object(); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs b/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs new file mode 100644 index 00000000..83ddefe1 --- /dev/null +++ b/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs @@ -0,0 +1,11 @@ +using System; + +namespace RateLimiter.Rules.Algorithms +{ + public class LeakyBucketConfiguration + { + public int Capacity { get; init; } + + public TimeSpan Interval { get; init; } + } +} diff --git a/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs b/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs deleted file mode 100644 index 9098787a..00000000 --- a/RateLimiter/Rules/Algorithms/LeakyBucketRule.cs +++ /dev/null @@ -1,18 +0,0 @@ -using RateLimiter.Abstractions; -using RateLimiter.Enums; - -using System; - -namespace RateLimiter.Rules.Algorithms; - -public class LeakyBucketRule : IRateLimitRuleAlgorithm -{ - public string Name { get; set; } - - public bool IsAllowed(string discriminator) - { - throw new NotImplementedException(); - } - - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.LeakyBucket; -} \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/SlidingWindow.cs b/RateLimiter/Rules/Algorithms/SlidingWindow.cs new file mode 100644 index 00000000..a106a5cc --- /dev/null +++ b/RateLimiter/Rules/Algorithms/SlidingWindow.cs @@ -0,0 +1,47 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; + +namespace RateLimiter.Rules.Algorithms; + +public class SlidingWindow : IAmARateLimitAlgorithm +{ + private readonly int _maxRequests; + private readonly TimeSpan _windowDuration; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly ConcurrentDictionary> _clientTimestamps; + + public SlidingWindow(IDateTimeProvider dateTimeProvider, int maxRequests, TimeSpan windowDuration) + { + _dateTimeProvider = dateTimeProvider; + _maxRequests = maxRequests; + _windowDuration = windowDuration; + } + + public string Name { get; init; } = nameof(SlidingWindow); + + public bool IsAllowed(string discriminator) + { + var now = _dateTimeProvider.UtcNow(); + var timestamps = _clientTimestamps.GetOrAdd(discriminator, _ => new List()); + + lock (timestamps) + { + // Remove timestamps older than the current window + var cutoff = now - _windowDuration; + timestamps.RemoveAll(t => t < cutoff); + + if (timestamps.Count >= _maxRequests) + return false; + + timestamps.Add(now); // Add current request timestamp + return true; + } + } + + public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.SlidingWindow; +} \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs b/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs deleted file mode 100644 index 1248801a..00000000 --- a/RateLimiter/Rules/Algorithms/SlidingWindowRule.cs +++ /dev/null @@ -1,18 +0,0 @@ -using RateLimiter.Abstractions; -using RateLimiter.Enums; - -using System; - -namespace RateLimiter.Rules.Algorithms; - -public class SlidingWindowRule : IRateLimitRuleAlgorithm -{ - public string Name { get; set; } - - public bool IsAllowed(string discriminator) - { - throw new NotImplementedException(); - } - - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.SlidingWindow; -} \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/TokenBucket.cs b/RateLimiter/Rules/Algorithms/TokenBucket.cs new file mode 100644 index 00000000..122e8dd5 --- /dev/null +++ b/RateLimiter/Rules/Algorithms/TokenBucket.cs @@ -0,0 +1,51 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules.Algorithms; + +public class TokenBucket : IAmARateLimitAlgorithm +{ + private readonly double _maxTokens; + private readonly double _refillRatePerSecond; // Tokens added per second + private readonly IDateTimeProvider _dateTimeProvider; + private readonly ConcurrentDictionary _buckets; + + public string Name { get; init; } = nameof(TokenBucket); + + public bool IsAllowed(string discriminator) + { + var bucket = _buckets.GetOrAdd(discriminator, _ => + new BucketState { Tokens = _maxTokens, LastRefillTime = _dateTimeProvider.UtcNow() }); + + lock (bucket.Lock) + { + var now = _dateTimeProvider.UtcNow(); + var timeElapsed = now - bucket.LastRefillTime; + + // Refill tokens based on elapsed time + double tokensToAdd = timeElapsed.TotalSeconds * _refillRatePerSecond; + bucket.Tokens = Math.Min(bucket.Tokens + tokensToAdd, _maxTokens); + bucket.LastRefillTime = now; + + if (bucket.Tokens >= 1.0) + { + bucket.Tokens -= 1.0; + return true; + } + + return false; + } + } + + public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.TokenBucket; + + private class BucketState + { + public double Tokens { get; set; } + public DateTime LastRefillTime { get; set; } + public object Lock { get; } = new object(); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/TokenBucketRule.cs b/RateLimiter/Rules/Algorithms/TokenBucketRule.cs deleted file mode 100644 index a92f957a..00000000 --- a/RateLimiter/Rules/Algorithms/TokenBucketRule.cs +++ /dev/null @@ -1,18 +0,0 @@ -using RateLimiter.Abstractions; -using RateLimiter.Enums; - -using System; - -namespace RateLimiter.Rules.Algorithms; - -public class TokenBucketRule : IRateLimitRuleAlgorithm -{ - public string Name { get; set; } - - public bool IsAllowed(string discriminator) - { - throw new NotImplementedException(); - } - - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.TokenBucket; -} \ No newline at end of file diff --git a/RateLimiter/Rules/RequestPerTimespanRule.cs b/RateLimiter/Rules/RequestPerTimespanRule.cs index d60c10b5..f0c8a2c1 100644 --- a/RateLimiter/Rules/RequestPerTimespanRule.cs +++ b/RateLimiter/Rules/RequestPerTimespanRule.cs @@ -5,7 +5,7 @@ namespace RateLimiter.Rules { - public class RequestPerTimespanRule : IDefineRateLimitRules + public class RequestPerTimespanRule : IDefineARateLimitRule { public LimiterType Type { get; } = LimiterType.RequestsPerTimespan; diff --git a/RateLimiter/Rules/TimespanElapsedRule.cs b/RateLimiter/Rules/TimespanElapsedRule.cs index 75f254a0..09b82ccf 100644 --- a/RateLimiter/Rules/TimespanElapsedRule.cs +++ b/RateLimiter/Rules/TimespanElapsedRule.cs @@ -5,7 +5,7 @@ namespace RateLimiter.Rules { - public class TimespanElapsedRule : IDefineRateLimitRules + public class TimespanElapsedRule : IDefineARateLimitRule { public LimiterType Type { get; } = LimiterType.TimespanElapsed; diff --git a/submission.md b/submission.md new file mode 100644 index 00000000..93d61388 --- /dev/null +++ b/submission.md @@ -0,0 +1,100 @@ +# RateLimiter +A class library for providing configurable and extensible rate limiting for web applications. +*** +## Approach +lorem ipsum +*** +## Decisions/Assumptions +Per the instructions, most of the time was spent around designing the rate limiting framework itself with much less concern about the implementation details for each of the four algorithms. + +While unit tests are provided for each, no time was spent running benchmarks in attempts to tweak performance and minimize memory usage. +*** +## Registration, Configuration & Usage +lorem ipsum +*** +### Service Registration +Registration of _RateLimiter's_ required services is provided via a fluent api. + +Example: +``` +builder.Services.AddRateLimiting() + .WithConfiguration(builder.Configuration.GetSection("RateLimiter")); +``` +*** +### Configuration +_RateLimiter_ can be configured via a standard appSettings.json section (or other configuration provider, i.e. Azure App Config) or via use of a fluent api. + +#### AppSettings.json Configuration +Configuration spec: + +``` +"RateLimiter": { + "DefaultAlgorithm": "FixedWindow|LeakyBucket|SlidingWindow|TokenBucket", + "DefaultMaxRequests": , + "DefaultTimespanMilliseconds": , + "Rules": [ + { + "Name": "MyDistinctRuleName", + "Type": "RequestPerTimespan|TimespanElapsed", + "Discriminator": "Custom|GeoLocation|IpAddress|IpSubnet|QueryString|RequestHeader", + "DiscriminatorMatch": , + "DiscriminatorCustomType": , + "MaxRequests": , + "TimespanMilliseconds": , + "Algorithm": "Default|FixedWindow|LeakyBucket|SlidingWindow|TokenBucket" + } + ] +} +``` +#### FluentApi Configuration +~~TBD~~ (will not be implemented at this time; please use json-based configuration) + +*** +### Usage in Controller-Based Applications +Registration of a rate limiting rule (or multiple rules) requires an attribute with a single parameter - the distinct name of the rule configured within the RateLimiter.Rules section. + +Example usage: + +``` +[RateLimitedResource(RuleName="MyFirstDistinctRuleName")] +[RateLimitedResource(RuleName="MySecondDistinctRuleName")] +[HttpGet(Name="GetWeatherForecast")] +public IEnumerable Get() { + // implementation +} +``` +*** +### Usage in MinimalApi-Based Application +Registration of a rate limiting rule (or multiple rules) requires usage of the FluentApi with a single parameter - the distinct name of the rule configured within the RateLimiter.Rules section. + +Example usage: +``` +app.MapGet("/weatherforecast", () => +{ + // implementation +}) +.WithName("GetWeatherForecast") +.WithRateLimitingRule("MyFirstDistinctRuleName") +.WithRateLimitingRule("MySecondDistinctRuleName"); +``` +*** +## Internal Class Hierarchy +lorem ipsum +*** +## Configurability +lorem ipsum +*** +## Extensibility +Consumers can add their own custom discriminators for more complex scenarios. The process of doing so consists of 3 parts: + +1. Provide a class that implements _IProvideADiscriminator_. +2. Create a rule in your [json-based configuration](#json-config-anchor-point) that specifies that class name in the _DiscriminatorCustomType_ property on a _Rules_ entry. +3. Modify the service registration to include your custom discriminator as shown below + +``` +builder.Services.AddRateLimiting() + .WithCustomDiscriminator() + .WithConfiguration(builder.Configuration.GetSection("RateLimiter")); +``` + +Multiple custom discriminators can be added provided they each have a unique name. A run-time exception will be thrown immediately upon application start in the case of a duplicated name. \ No newline at end of file From 6e4becae9f92531ee7d0f6bcdb3b2166b1064984 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Tue, 11 Feb 2025 17:30:58 -0500 Subject: [PATCH 07/29] cleanup and PR prep --- .../Controllers/WeatherForecastController.cs | 1 + .../RateLimiting/GeoTokenDiscriminator.cs | 22 ++- RateLimiter.Tests.Api/appsettings.json | 22 +-- .../DiscriminatorProviderTests.cs | 82 ++++++++++ .../Algorithms/AlgorithmProviderTests.cs | 35 ++++ RateLimiter.Tests/UnitTestBase.cs | 19 +++ RateLimiter.sln.DotSettings.user | 1 + .../Abstractions/IDefineARateLimitRule.cs | 2 +- .../Abstractions/IProvideADiscriminator.cs | 2 +- .../IProvideDiscriminatorValues.cs | 6 +- .../IProvideRateLimitAlgorithms.cs | 14 ++ .../RateLimiterRegister.cs | 4 +- .../Discriminators/DiscriminatorProvider.cs | 103 +++++++++--- .../Discriminators/GeoBasedDiscriminator.cs | 4 +- .../Discriminators/IpAddressDiscriminator.cs | 12 +- .../QueryStringDiscriminator.cs | 23 ++- RateLimiter/Discriminators/Readme.md | 1 - .../RequestHeaderDiscriminator.cs | 31 ++++ RateLimiter/Enums/RateLimitingAlgorithm.cs | 1 + RateLimiter/RateLimiter.cs | 152 ++++++++++++------ RateLimiter/RateLimiterRulesFactory.cs | 4 +- .../Rules/Algorithms/AlgorithmProvider.cs | 65 ++++++++ .../Algorithms/FixedWindowConfiguration.cs | 11 +- RateLimiter/Rules/Algorithms/SlidingWindow.cs | 9 +- .../Algorithms/SlidingWindowConfiguration.cs | 11 ++ .../Rules/Algorithms/TimespanElapsed.cs | 46 ++++++ RateLimiter/Rules/Algorithms/TokenBucket.cs | 15 +- .../Algorithms/TokenBucketConfiguration.cs | 9 ++ RateLimiter/Rules/RequestPerTimespanRule.cs | 2 +- RateLimiter/Rules/TimespanElapsedRule.cs | 3 +- submission.md | 12 +- 31 files changed, 585 insertions(+), 139 deletions(-) create mode 100644 RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs create mode 100644 RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs create mode 100644 RateLimiter.Tests/UnitTestBase.cs create mode 100644 RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs delete mode 100644 RateLimiter/Discriminators/Readme.md create mode 100644 RateLimiter/Discriminators/RequestHeaderDiscriminator.cs create mode 100644 RateLimiter/Rules/Algorithms/AlgorithmProvider.cs create mode 100644 RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs create mode 100644 RateLimiter/Rules/Algorithms/TimespanElapsed.cs create mode 100644 RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs index 2d2b7421..3c53805b 100644 --- a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -21,6 +21,7 @@ public WeatherForecastController(ILogger logger) } [RateLimitedResource(RuleName = "GeoTokenRule-US")] + [RateLimitedResource(RuleName = "GeoTokenRule-EU")] //[RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] [HttpGet(Name = "GetWeatherForecast")] public IEnumerable Get() diff --git a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs index 395e9c1d..dc067788 100644 --- a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs +++ b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs @@ -1,11 +1,29 @@ using RateLimiter.Abstractions; +using RateLimiter.Discriminators; namespace RateLimiter.Tests.Api.Middleware.RateLimiting; public class GeoTokenDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + /// + /// This functionality could have been obtained using , but showing extensibility + /// + /// + /// + /// + public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { - return context.Request.Headers["x-crexi-token"].FirstOrDefault() ?? string.Empty; + if (!context.Request.Headers.TryGetValue("x-crexi-token", out var value)) + { + return (false, string.Empty); + } + + if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || + rateLimitRule.DiscriminatorMatch == "*") + return (true, value.ToString()); + + return rateLimitRule.DiscriminatorMatch == value.ToString() ? + (true, value.ToString()) : + (false, value.ToString()); } } \ No newline at end of file diff --git a/RateLimiter.Tests.Api/appsettings.json b/RateLimiter.Tests.Api/appsettings.json index c3575e25..4ac31880 100644 --- a/RateLimiter.Tests.Api/appsettings.json +++ b/RateLimiter.Tests.Api/appsettings.json @@ -15,27 +15,11 @@ "Name": "RequestsPerTimespan-Default", "Type": "RequestsPerTimespan", "Discriminator": "IpAddress", - "DiscriminatorMatch": "*.*.*.*", + "DiscriminatorMatch": "*", "MaxRequests": 3, "TimespanMilliseconds": 3000, "Algorithm": "Default" }, - { - "Name": "TimespanElapsed-Default", - "Type": "TimespanElapsed", - "Discriminator": "IpAddress", - "DiscriminatorMatch": "*.*.*.*", - "TimespanMilliseconds": 5000, - "Algorithm": "LeakyBucket" - }, - { - "Name": "GeoBased", - "Type": "TimespanElapsed", - "Discriminator": "GeoLocation", - "DiscriminatorMatch": "US|FR|MX|TH", - "TimespanMilliseconds": 5000, - "Algorithm": "Default" - }, { "Name": "GeoTokenRule-US", "Type": "RequestsPerTimespan", @@ -52,8 +36,8 @@ "Discriminator": "Custom", "DiscriminatorMatch": "EU", "CustomDiscriminatorType": "GeoTokenDiscriminator", - "TimespanMilliseconds": 500, - "Algorithm": "Default" + "TimespanMilliseconds": 3000, + "Algorithm": "TimespanElapsed" } ] } diff --git a/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs b/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs new file mode 100644 index 00000000..bdb89e38 --- /dev/null +++ b/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs @@ -0,0 +1,82 @@ +using FluentAssertions; + +using HttpContextMoq; +using HttpContextMoq.Extensions; + +using Microsoft.Extensions.Primitives; + +using RateLimiter.Abstractions; +using RateLimiter.Discriminators; +using RateLimiter.Enums; +using RateLimiter.Rules; + +using System; +using System.Collections.Generic; + +using Xunit; + +namespace RateLimiter.Tests.Discriminators +{ + public class DiscriminatorProviderTests : UnitTestBase + { + [Fact] + public void GetDiscriminatorValues_OnValidData_GetsValues() + { + // arrange + var context = new HttpContextMock() + .SetupUrl("http://localhost:8000/path") + .SetupRequestHeaders(new Dictionary() + { + { "Host", "192.168.0.1"} + }) + .SetupRequestMethod("GET"); + + var sut = Mocker.CreateInstance(); + + // act + var result = sut.GetDiscriminatorValues(context, rules); + + // assert + result.Count.Should().Be(rules.Count); + } + + private List rules = + [ + new RequestPerTimespanRule() + { + Algorithm = RateLimitingAlgorithm.FixedWindow, + CustomDiscriminatorName = string.Empty, + Discriminator = LimiterDiscriminator.QueryString, + DiscriminatorMatch = "someQuerystringValue", + DiscriminatorKey = string.Empty, + MaxRequests = 5, + Name = $"My{nameof(LimiterDiscriminator.QueryString)}", + TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) + }, + + new RequestPerTimespanRule() + { + Algorithm = RateLimitingAlgorithm.FixedWindow, + CustomDiscriminatorName = string.Empty, + Discriminator = LimiterDiscriminator.RequestHeader, + DiscriminatorMatch = string.Empty, + DiscriminatorKey = "Host", + MaxRequests = 5, + Name = $"My{nameof(LimiterDiscriminator.RequestHeader)}", + TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) + }, + + new RequestPerTimespanRule() + { + Algorithm = RateLimitingAlgorithm.FixedWindow, + CustomDiscriminatorName = string.Empty, + Discriminator = LimiterDiscriminator.IpAddress, + DiscriminatorMatch = string.Empty, + DiscriminatorKey = string.Empty, + MaxRequests = 5, + Name = $"My{nameof(LimiterDiscriminator.IpAddress)}", + TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) + } + ]; + } +} diff --git a/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs b/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs new file mode 100644 index 00000000..723fdbd7 --- /dev/null +++ b/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs @@ -0,0 +1,35 @@ +using FluentAssertions; + +using RateLimiter.Enums; +using RateLimiter.Rules.Algorithms; + +using System; + +using Xunit; + +namespace RateLimiter.Tests.Rules.Algorithms +{ + public class AlgorithmProviderTests : UnitTestBase + { + [Theory] + [InlineData(RateLimitingAlgorithm.Default, RateLimitingAlgorithm.FixedWindow)] + [InlineData(RateLimitingAlgorithm.FixedWindow, RateLimitingAlgorithm.FixedWindow)] + [InlineData(RateLimitingAlgorithm.LeakyBucket, RateLimitingAlgorithm.LeakyBucket)] + [InlineData(RateLimitingAlgorithm.SlidingWindow, RateLimitingAlgorithm.SlidingWindow)] + [InlineData(RateLimitingAlgorithm.TokenBucket, RateLimitingAlgorithm.TokenBucket)] + public void GetAlgorithm_WithValidData_ProvidesCorrectAlgorithm( + RateLimitingAlgorithm algo, + RateLimitingAlgorithm expectedAlgorithm) + { + // arrange + var sut = Mocker.CreateInstance(); + + // act + var result = sut.GetAlgorithm(algo, 5, TimeSpan.FromMilliseconds(3000)); + + // assert + //result.Name.Should().Be(typeof(algo)); + result.Algorithm.Should().Be(expectedAlgorithm); + } + } +} diff --git a/RateLimiter.Tests/UnitTestBase.cs b/RateLimiter.Tests/UnitTestBase.cs new file mode 100644 index 00000000..a2792823 --- /dev/null +++ b/RateLimiter.Tests/UnitTestBase.cs @@ -0,0 +1,19 @@ +using AutoFixture; + +using Moq.AutoMock; + +namespace RateLimiter.Tests +{ + public abstract class UnitTestBase + { + public AutoMocker Mocker { get; } + + public Fixture Fixture { get; } + + internal UnitTestBase() + { + Mocker = new AutoMocker(); + Fixture = new Fixture(); + } + } +} diff --git a/RateLimiter.sln.DotSettings.user b/RateLimiter.sln.DotSettings.user index 66fbb056..9bb83526 100644 --- a/RateLimiter.sln.DotSettings.user +++ b/RateLimiter.sln.DotSettings.user @@ -1,4 +1,5 @@  + C:\Users\Randall\AppData\Local\Temp\JetBrains\ReSharperPlatformVs17\vAny_6feb4319\CoverageData\_RateLimiter.1996142771\Snapshot\snapshot.utdcvr <SessionState ContinuousTestingMode="0" IsActive="True" Name="WhenFoo_DoesBar" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Project Location="C:\Projects\crexi\rate-limiter\RateLimiter.Tests" Presentation="&lt;test&gt;\&lt;RateLimiter.Tests&gt;" /> </SessionState> \ No newline at end of file diff --git a/RateLimiter/Abstractions/IDefineARateLimitRule.cs b/RateLimiter/Abstractions/IDefineARateLimitRule.cs index 4d4f21c3..839ac3f3 100644 --- a/RateLimiter/Abstractions/IDefineARateLimitRule.cs +++ b/RateLimiter/Abstractions/IDefineARateLimitRule.cs @@ -12,7 +12,7 @@ public interface IDefineARateLimitRule string? CustomDiscriminatorName { get; set; } - string? DiscriminatorRequestHeaderKey { get; set; } + string? DiscriminatorKey { get; set; } string? DiscriminatorMatch { get; set; } diff --git a/RateLimiter/Abstractions/IProvideADiscriminator.cs b/RateLimiter/Abstractions/IProvideADiscriminator.cs index c9d13f74..146d268e 100644 --- a/RateLimiter/Abstractions/IProvideADiscriminator.cs +++ b/RateLimiter/Abstractions/IProvideADiscriminator.cs @@ -4,6 +4,6 @@ namespace RateLimiter.Abstractions { public interface IProvideADiscriminator { - string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule); + (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule); } } diff --git a/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs b/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs index 38ace2c9..e5eae9c1 100644 --- a/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs +++ b/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs @@ -1,12 +1,12 @@ -using System.Collections; +using Microsoft.AspNetCore.Http; + using System.Collections.Generic; -using Microsoft.AspNetCore.Http; namespace RateLimiter.Abstractions; public interface IProvideDiscriminatorValues { - Hashtable GetDiscriminatorValues( + Dictionary GetDiscriminatorValues( HttpContext context, IEnumerable rules); } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs b/RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs new file mode 100644 index 00000000..f87be154 --- /dev/null +++ b/RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs @@ -0,0 +1,14 @@ +using RateLimiter.Enums; + +using System; + +namespace RateLimiter.Abstractions +{ + public interface IProvideRateLimitAlgorithms + { + IAmARateLimitAlgorithm GetAlgorithm( + RateLimitingAlgorithm algo, + int? maxRequests, + TimeSpan? timespanMilliseconds); + } +} diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs index 89e2e647..29a7b619 100644 --- a/RateLimiter/DependencyInjection/RateLimiterRegister.cs +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -7,6 +7,7 @@ using RateLimiter.Config; using RateLimiter.Discriminators; using RateLimiter.Middleware; +using RateLimiter.Rules.Algorithms; namespace RateLimiter.DependencyInjection; @@ -17,6 +18,7 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; } @@ -36,7 +38,7 @@ public static IServiceCollection WithCustomDiscriminator(this IServiceCollect public static IServiceCollection WithConfiguration( this IServiceCollection services, - IConfigurationSection section) + IConfigurationSection section) where TRateLimiterConfiguration : RateLimiterConfiguration { services.Configure(section); return services; diff --git a/RateLimiter/Discriminators/DiscriminatorProvider.cs b/RateLimiter/Discriminators/DiscriminatorProvider.cs index caee1239..3ff7065a 100644 --- a/RateLimiter/Discriminators/DiscriminatorProvider.cs +++ b/RateLimiter/Discriminators/DiscriminatorProvider.cs @@ -6,7 +6,7 @@ using RateLimiter.Enums; using System; -using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; namespace RateLimiter.Discriminators @@ -16,6 +16,8 @@ public class DiscriminatorProvider : IProvideDiscriminatorValues private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; + private readonly ConcurrentDictionary _discriminators = new(); + public DiscriminatorProvider( ILogger logger, IServiceProvider serviceProvider) @@ -24,13 +26,13 @@ public DiscriminatorProvider( _serviceProvider = serviceProvider; } - public Hashtable GetDiscriminatorValues( + public Dictionary GetDiscriminatorValues( HttpContext context, IEnumerable rules) { // TODO: These values should likely be cached in the caller - var results = new Hashtable(); + var results = new Dictionary(); // for each rule in here, we need to generate the discriminator value foreach (var rule in rules) @@ -38,34 +40,16 @@ public Hashtable GetDiscriminatorValues( switch (rule.Discriminator) { case LimiterDiscriminator.QueryString: - var qsd = new QueryStringDiscriminator(); - var qsdResult = qsd.GetDiscriminator(context, rule); - results.Add(rule.Name, qsdResult); + results.Add(rule.Name, GetQuerystringValue(context, rule)); break; case LimiterDiscriminator.RequestHeader: - if (string.IsNullOrEmpty(rule.DiscriminatorRequestHeaderKey)) - { - // TODO: Log - throw new MissingFieldException($"{nameof(rule.DiscriminatorRequestHeaderKey)} was not provided"); - } - results.Add(rule.Name, context.Request.Query[rule.DiscriminatorRequestHeaderKey]); + results.Add(rule.Name, GetRequestHeaderValue(context, rule)); break; case LimiterDiscriminator.IpAddress: - var ipad = new IpAddressDiscriminator(); - var ipadResult = ipad.GetDiscriminator(context, rule); - results.Add(rule.Name, ipadResult); + results.Add(rule.Name, GetIpAddressValue(context, rule)); break; case LimiterDiscriminator.Custom: - // hmmm ... need to instantiate the custom discriminator registered and execute it? - if (string.IsNullOrEmpty(rule.CustomDiscriminatorName)) - { - throw new MissingFieldException("No value for {@CustomDiscriminatorName", - nameof(rule.CustomDiscriminatorName)); - } - - var foo = _serviceProvider.GetRequiredKeyedService(rule.CustomDiscriminatorName); - var fooValue = foo.GetDiscriminator(context, rule); - results.Add(rule.Name, fooValue); + results.Add(rule.Name, GetCustomValue(_serviceProvider, context, rule)); break; case LimiterDiscriminator.GeoLocation: case LimiterDiscriminator.IpSubNet: @@ -76,5 +60,74 @@ public Hashtable GetDiscriminatorValues( return results; } + + // TODO: Refactor to generic + private (bool IsMatch, string MatchValue) GetCustomValue(IServiceProvider serviceProvider, HttpContext context, IDefineARateLimitRule rule) + { + IProvideADiscriminator discriminator; + + if (!_discriminators.TryGetValue(rule.Name, out var value)) + { + discriminator = serviceProvider.GetRequiredKeyedService(rule.CustomDiscriminatorName); + _discriminators.TryAdd(rule.Name, discriminator); + } + else + { + discriminator = value; + } + + return discriminator.GetDiscriminator(context, rule); + } + + private (bool IsMatch, string MatchValue) GetIpAddressValue(HttpContext context, IDefineARateLimitRule rule) + { + IProvideADiscriminator discriminator; + + if (!_discriminators.TryGetValue(rule.Name, out var value)) + { + discriminator = new IpAddressDiscriminator(); + _discriminators.TryAdd(rule.Name, discriminator); + } + else + { + discriminator = value; + } + + return discriminator.GetDiscriminator(context, rule); + } + + private (bool IsMatch, string MatchValue) GetRequestHeaderValue(HttpContext context, IDefineARateLimitRule rule) + { + IProvideADiscriminator discriminator; + + if (!_discriminators.TryGetValue(rule.Name, out var value)) + { + discriminator = new RequestHeaderDiscriminator(); + _discriminators.TryAdd(rule.Name, discriminator); + } + else + { + discriminator = value; + } + + return discriminator.GetDiscriminator(context, rule); + } + + private (bool IsMatch, string MatchValue) GetQuerystringValue(HttpContext context, IDefineARateLimitRule rule) + { + IProvideADiscriminator discriminator; + + if (!_discriminators.TryGetValue(rule.Name, out var value)) + { + discriminator = new QueryStringDiscriminator(); + _discriminators.TryAdd(rule.Name, discriminator); + } + else + { + discriminator = value; + } + + return discriminator.GetDiscriminator(context, rule); + } } } diff --git a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs index 7dfa2075..2ffff4b7 100644 --- a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs +++ b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs @@ -6,14 +6,14 @@ namespace RateLimiter.Discriminators { public class GeoBasedDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { // get the ip address via cache/external source // perform a geo lookup on it // return the geolocation - return "US"; + return (false, "US"); } } } diff --git a/RateLimiter/Discriminators/IpAddressDiscriminator.cs b/RateLimiter/Discriminators/IpAddressDiscriminator.cs index e516a134..15b0a693 100644 --- a/RateLimiter/Discriminators/IpAddressDiscriminator.cs +++ b/RateLimiter/Discriminators/IpAddressDiscriminator.cs @@ -6,10 +6,18 @@ namespace RateLimiter.Discriminators { public class IpAddressDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { // TODO: This is likely incorrect. Cannot test b/c shows "localhost" - return context.Request.Headers.Host.ToString(); + var ipAddress = context.Request.Headers.Host.ToString(); + + if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || + rateLimitRule.DiscriminatorMatch == "*") + return (true, ipAddress); + + return rateLimitRule.DiscriminatorMatch == ipAddress ? + (true, ipAddress) : + (false, ipAddress); } } } diff --git a/RateLimiter/Discriminators/QueryStringDiscriminator.cs b/RateLimiter/Discriminators/QueryStringDiscriminator.cs index b5183616..346cef91 100644 --- a/RateLimiter/Discriminators/QueryStringDiscriminator.cs +++ b/RateLimiter/Discriminators/QueryStringDiscriminator.cs @@ -2,15 +2,30 @@ using RateLimiter.Abstractions; -using System; - namespace RateLimiter.Discriminators { public class QueryStringDiscriminator : IProvideADiscriminator { - public string GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) { - throw new NotImplementedException(); + if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorKey)) + { + // likely should log and throw + return (false, string.Empty); + } + + if (!context.Request.Query.TryGetValue(rateLimitRule.DiscriminatorKey, out var value)) + { + return (false, string.Empty); + } + + if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || + rateLimitRule.DiscriminatorMatch == "*") + return (true, value.ToString()); + + return rateLimitRule.DiscriminatorMatch == value.ToString() ? + (true, value.ToString()) : + (false, value.ToString()); } } } diff --git a/RateLimiter/Discriminators/Readme.md b/RateLimiter/Discriminators/Readme.md deleted file mode 100644 index 5f282702..00000000 --- a/RateLimiter/Discriminators/Readme.md +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/RateLimiter/Discriminators/RequestHeaderDiscriminator.cs b/RateLimiter/Discriminators/RequestHeaderDiscriminator.cs new file mode 100644 index 00000000..2ff3f87f --- /dev/null +++ b/RateLimiter/Discriminators/RequestHeaderDiscriminator.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Http; + +using RateLimiter.Abstractions; + +namespace RateLimiter.Discriminators +{ + public class RequestHeaderDiscriminator : IProvideADiscriminator + { + public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + { + if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorKey)) + { + // likely should log and throw + return (false, string.Empty); + } + + if (!context.Request.Headers.TryGetValue(rateLimitRule.DiscriminatorKey, out var value)) + { + return (false, string.Empty); + } + + if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || + rateLimitRule.DiscriminatorMatch == "*") + return (true, value.ToString()); + + return rateLimitRule.DiscriminatorMatch == value.ToString() ? + (true, value.ToString()) : + (false, value.ToString()); + } + } +} diff --git a/RateLimiter/Enums/RateLimitingAlgorithm.cs b/RateLimiter/Enums/RateLimitingAlgorithm.cs index a8919193..1c5fb314 100644 --- a/RateLimiter/Enums/RateLimitingAlgorithm.cs +++ b/RateLimiter/Enums/RateLimitingAlgorithm.cs @@ -6,5 +6,6 @@ public enum RateLimitingAlgorithm FixedWindow, LeakyBucket, SlidingWindow, + TimespanElapsed, TokenBucket } \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 820933c5..713a828e 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -6,9 +6,9 @@ using RateLimiter.Config; using RateLimiter.Enums; using RateLimiter.Rules; -using RateLimiter.Rules.Algorithms; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -17,7 +17,7 @@ namespace RateLimiter; public class RateLimiter : IRateLimitRequests { private readonly ILogger _logger; - private readonly IDateTimeProvider _dateTimeProvider; + private readonly IProvideRateLimitAlgorithms _algorithmProvider; /// /// List of rules as defined in appSettings.RateLimiter section (or via Fluent registration) @@ -25,17 +25,19 @@ public class RateLimiter : IRateLimitRequests private readonly IEnumerable _rules; private readonly IProvideDiscriminatorValues _discriminatorsProvider; - private readonly Dictionary _ruleNameAlgorithm; + private readonly ConcurrentDictionary _ruleNameAlgorithm; public RateLimiter( ILogger logger, - IDateTimeProvider dateTimeProvider, IOptions options, IProvideRateLimitRules rulesFactory, - IProvideDiscriminatorValues discriminatorsProvider) + IProvideDiscriminatorValues discriminatorsProvider, + IProvideRateLimitAlgorithms algorithmProvider) { _logger = logger; - _dateTimeProvider = dateTimeProvider; + _algorithmProvider = algorithmProvider; + + // TODO: IOptions should be replaced with IOptionsMonitor for hot-reloading _rules = rulesFactory.GetRules(options.Value); // We need to instantiate an instance of an algorithm for each configuration we find @@ -43,6 +45,54 @@ public RateLimiter( _ruleNameAlgorithm = GenerateAlgorithmsFromRules(_rules); _discriminatorsProvider = discriminatorsProvider; + + ValidateConfiguration(); + } + + /// + /// Validate configuration and registrations upon instantiation in order to prevent downstream runtime errors & exceptions + /// + private void ValidateConfiguration() + { + foreach (var rule in _rules) + { + switch (rule.Discriminator) + { + case LimiterDiscriminator.Custom: + if (string.IsNullOrEmpty(rule.CustomDiscriminatorName)) + throw new MissingFieldException("Rule uses a custom discriminator, but none was provided. {@RuleName}", rule.Name); + + break; + case LimiterDiscriminator.GeoLocation: + case LimiterDiscriminator.IpAddress: + case LimiterDiscriminator.IpSubNet: + break; + case LimiterDiscriminator.QueryString: + if (string.IsNullOrEmpty(rule.DiscriminatorKey)) + throw new MissingFieldException("Rule uses a querystring discriminator, but DiscriminatorKey was not provided. {@RuleName}", rule.Name); + break; + case LimiterDiscriminator.RequestHeader: + if (string.IsNullOrEmpty(rule.DiscriminatorKey)) + throw new MissingFieldException("Rule uses a request header discriminator, but DiscriminatorKey was not provided. {@RuleName}", rule.Name); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + switch (rule.Type) + { + case LimiterType.RequestsPerTimespan: + break; + case LimiterType.TimespanElapsed: + // ensure this is correct; cannot be anything else for this rule type + // do not throw an exception, simply correct it + if (rule.Algorithm != RateLimitingAlgorithm.TimespanElapsed) + rule.Algorithm = RateLimitingAlgorithm.TimespanElapsed; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } } public (bool RequestIsAllowed, string ErrorMessage) IsRequestAllowed( @@ -61,28 +111,30 @@ public RateLimiter( return (true, string.Empty); } - // get the algorithm required for each rule to be evaluated (move to ctor?) - //var requiredAlgorithms = _ruleNameAlgorithm - // .Where(x => matchingRules.Select(y => y.Name) - // .Contains(x.Key)) - // .Select(x => x.Value); - // need to get the discriminator for each incoming rate limit configuration + // key: ruleName value: (IsMatch, MatchValue) var discriminatorValues = _discriminatorsProvider - .GetDiscriminatorValues(context, matchingRules); //key: name value: discriminatorValue + .GetDiscriminatorValues(context, matchingRules) + .Where(x => x.Value.IsMatch) + .ToList(); + + // now we need to filter down the matchingRules only to those whose discriminators matched their condition(s) + matchingRules = matchingRules + .Where(x => discriminatorValues.Select(y => y.Key) + .Contains(x.Name)) + .ToList(); // TODO: Make this a single call (no iterations) var passed = true; foreach (var rule in matchingRules) { - passed = _ruleNameAlgorithm[rule.Name].IsAllowed(discriminatorValues[rule.Name].ToString()); + passed = _ruleNameAlgorithm[rule.Name] + .IsAllowed(discriminatorValues.First(x => x.Key == rule.Name).Value.MatchValue); if (!passed) break; } - // ensure they all pass - //var passed = algorithms.All(x => x.IsAllowed(discriminators[x.Name].ToString())); - + // TODO: We would want to make this configurable - what status code to use and what we tell the client return passed ? (passed, string.Empty) : (passed, "some message about banging on our door too much"); } @@ -95,43 +147,64 @@ public RateLimiter( /// /// /// - private Dictionary GenerateAlgorithmsFromRules(IEnumerable rules) + private ConcurrentDictionary GenerateAlgorithmsFromRules(IEnumerable rules) { - var values = new Dictionary(); + var values = new ConcurrentDictionary(); - var algorithms = new Dictionary(); + var algorithms = new ConcurrentDictionary(); foreach (var rule in rules) { + // TODO: Clean up this mess! switch (rule.Type) { case LimiterType.RequestsPerTimespan: if (rule is not RequestPerTimespanRule typedRule) - { throw new InvalidCastException("uh oh"); - } // do we have an algorithm that meets these requirements? - var algoKey = $"{typedRule.Algorithm}|{typedRule.MaxRequests}|{typedRule.TimespanMilliseconds}"; + var rptKey = $"{typedRule.Algorithm}|{typedRule.MaxRequests}|{typedRule.TimespanMilliseconds}"; - if (!algorithms.TryGetValue(algoKey, out var existingAlgo)) + if (!algorithms.TryGetValue(rptKey, out var existingAlgo)) { // create the required algo with the required config - var algo = GetAlgorithm( - _dateTimeProvider, + var algo = _algorithmProvider.GetAlgorithm( typedRule.Algorithm, typedRule.MaxRequests, typedRule.TimespanMilliseconds); - values.Add(typedRule.Name, algo); - algorithms.Add(algoKey, algo); + values.TryAdd(typedRule.Name, algo); + algorithms.TryAdd(rptKey, algo); } else { - values.Add(typedRule.Name, existingAlgo); + values.TryAdd(typedRule.Name, existingAlgo); } + break; case LimiterType.TimespanElapsed: + + if (rule is not TimespanElapsedRule teRule) + throw new InvalidCastException("uh oh"); + + // do we have an algorithm that meets these requirements? + var teKey = $"{teRule.Algorithm}|{teRule.TimespanSinceMilliseconds}"; + + if (!algorithms.TryGetValue(teKey, out var existingTeAlgo)) + { + // create the required algo with the required config + var algo = _algorithmProvider.GetAlgorithm( + teRule.Algorithm, + null, + teRule.TimespanSinceMilliseconds); + values.TryAdd(teRule.Name, algo); + algorithms.TryAdd(teKey, algo); + } + else + { + values.TryAdd(teRule.Name, existingTeAlgo); + } + break; default: throw new ArgumentOutOfRangeException(); @@ -140,25 +213,4 @@ private Dictionary GenerateAlgorithmsFromRules(I return values; } - - private static IAmARateLimitAlgorithm GetAlgorithm( - IDateTimeProvider dateTimeProvider, - RateLimitingAlgorithm algo, - int? maxRequests, - TimeSpan? timespanMilliseconds) - { - return algo switch - { - RateLimitingAlgorithm.Default or RateLimitingAlgorithm.FixedWindow => new FixedWindow(dateTimeProvider, - new FixedWindowConfiguration() - { - MaxRequests = maxRequests.Value, - WindowDuration = timespanMilliseconds.Value - }), - RateLimitingAlgorithm.TokenBucket => new TokenBucket(), - RateLimitingAlgorithm.LeakyBucket => new LeakyBucket(dateTimeProvider, maxRequests.Value, timespanMilliseconds.Value), - RateLimitingAlgorithm.SlidingWindow => new SlidingWindow(dateTimeProvider, maxRequests.Value, timespanMilliseconds.Value), - _ => throw new ArgumentOutOfRangeException(nameof(algo), algo, null) - }; - } } \ No newline at end of file diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs index 88e791f2..7da8d1b4 100644 --- a/RateLimiter/RateLimiterRulesFactory.cs +++ b/RateLimiter/RateLimiterRulesFactory.cs @@ -27,7 +27,7 @@ public IEnumerable GetRules(RateLimiterConfiguration conf Discriminator = rule.Discriminator, CustomDiscriminatorName = rule.CustomDiscriminatorType, DiscriminatorMatch = rule.DiscriminatorMatch, - DiscriminatorRequestHeaderKey = rule.DiscriminatorRequestHeaderKey, + DiscriminatorKey = rule.DiscriminatorRequestHeaderKey, MaxRequests = rule.MaxRequests ?? configuration.DefaultMaxRequests, TimespanMilliseconds = rule.TimespanMilliseconds is null ? TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : @@ -42,7 +42,7 @@ public IEnumerable GetRules(RateLimiterConfiguration conf Discriminator = rule.Discriminator, CustomDiscriminatorName = rule.CustomDiscriminatorType, DiscriminatorMatch = rule.DiscriminatorMatch, - DiscriminatorRequestHeaderKey = rule.DiscriminatorRequestHeaderKey, + DiscriminatorKey = rule.DiscriminatorRequestHeaderKey, TimespanSinceMilliseconds = rule.TimespanMilliseconds is null ? TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : TimeSpan.FromMilliseconds(rule.TimespanMilliseconds.Value) diff --git a/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs b/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs new file mode 100644 index 00000000..c715e88b --- /dev/null +++ b/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Options; + +using RateLimiter.Abstractions; +using RateLimiter.Config; +using RateLimiter.Enums; + +using System; + +namespace RateLimiter.Rules.Algorithms +{ + public class AlgorithmProvider : IProvideRateLimitAlgorithms + { + private readonly IDateTimeProvider _dateTimeProvider; + private readonly IOptions _options; + + public AlgorithmProvider( + IDateTimeProvider dateTimeProvider, + IOptions options) + { + _dateTimeProvider = dateTimeProvider; + _options = options; + } + + public IAmARateLimitAlgorithm GetAlgorithm( + RateLimitingAlgorithm algo, + int? maxRequests, + TimeSpan? timespanMilliseconds) + { + return algo switch + { + RateLimitingAlgorithm.Default or RateLimitingAlgorithm.FixedWindow => new FixedWindow(_dateTimeProvider, + new FixedWindowConfiguration() + { + MaxRequests = maxRequests ?? _options.Value.DefaultMaxRequests, + WindowDuration = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) + }), + RateLimitingAlgorithm.TokenBucket => new TokenBucket(_dateTimeProvider, + new TokenBucketConfiguration() + { + // TODO: Move to config + MaxTokens = 10, + RefillRatePerSecond = 10 + }), + RateLimitingAlgorithm.LeakyBucket => new LeakyBucket(_dateTimeProvider, + new LeakyBucketConfiguration() + { + Capacity = maxRequests ?? _options.Value.DefaultMaxRequests, + Interval = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) + }), + RateLimitingAlgorithm.SlidingWindow => new SlidingWindow(_dateTimeProvider, + new SlidingWindowConfiguration() + { + MaxRequests = maxRequests ?? _options.Value.DefaultMaxRequests, + WindowDuration = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) + }), + RateLimitingAlgorithm.TimespanElapsed => new TimespanElapsed(_dateTimeProvider, + new TimespanElapsedConfiguration() + { + MinInterval = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) + }), + _ => throw new ArgumentOutOfRangeException(nameof(algo), algo, null) + }; + } + } +} diff --git a/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs b/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs index fa723778..1c6f2093 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs @@ -1,18 +1,11 @@ -using RateLimiter.Enums; +using System; -using System; - -namespace RateLimiter.Rules; +namespace RateLimiter.Rules.Algorithms; public record FixedWindowConfiguration { - public string Name { get; set; } public int MaxRequests { get; init; } public TimeSpan WindowDuration { get; init; } - - public LimiterDiscriminator Discriminator { get; init; } - - public string? CustomDiscriminatorType { get; init; } } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/SlidingWindow.cs b/RateLimiter/Rules/Algorithms/SlidingWindow.cs index a106a5cc..b5ad6b76 100644 --- a/RateLimiter/Rules/Algorithms/SlidingWindow.cs +++ b/RateLimiter/Rules/Algorithms/SlidingWindow.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Threading; namespace RateLimiter.Rules.Algorithms; @@ -13,13 +12,13 @@ public class SlidingWindow : IAmARateLimitAlgorithm private readonly int _maxRequests; private readonly TimeSpan _windowDuration; private readonly IDateTimeProvider _dateTimeProvider; - private readonly ConcurrentDictionary> _clientTimestamps; + private readonly ConcurrentDictionary> _clientTimestamps = new(); - public SlidingWindow(IDateTimeProvider dateTimeProvider, int maxRequests, TimeSpan windowDuration) + public SlidingWindow(IDateTimeProvider dateTimeProvider, SlidingWindowConfiguration configuration) { _dateTimeProvider = dateTimeProvider; - _maxRequests = maxRequests; - _windowDuration = windowDuration; + _maxRequests = configuration.MaxRequests; + _windowDuration = configuration.WindowDuration; } public string Name { get; init; } = nameof(SlidingWindow); diff --git a/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs b/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs new file mode 100644 index 00000000..e6dac69a --- /dev/null +++ b/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs @@ -0,0 +1,11 @@ +using System; + +namespace RateLimiter.Rules.Algorithms +{ + public class SlidingWindowConfiguration + { + public int MaxRequests { get; init; } + + public TimeSpan WindowDuration { get; init; } + } +} diff --git a/RateLimiter/Rules/Algorithms/TimespanElapsed.cs b/RateLimiter/Rules/Algorithms/TimespanElapsed.cs new file mode 100644 index 00000000..37e286f0 --- /dev/null +++ b/RateLimiter/Rules/Algorithms/TimespanElapsed.cs @@ -0,0 +1,46 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules.Algorithms +{ + public class TimespanElapsed : IAmARateLimitAlgorithm + { + private readonly TimeSpan _minInterval; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly ConcurrentDictionary _lastCallTimes = new(); + + public TimespanElapsed( + IDateTimeProvider dateTimeProvider, + TimespanElapsedConfiguration configuration) + { + _dateTimeProvider = dateTimeProvider; + _minInterval = configuration.MinInterval; + } + + public string Name { get; init; } = nameof(TimespanElapsed); + + public bool IsAllowed(string discriminator) + { + var now = _dateTimeProvider.UtcNow(); + var lastCall = _lastCallTimes.GetOrAdd(discriminator, DateTime.MinValue); + + if ((now - lastCall) < _minInterval) + { + return false; // Too soon since last call + } + + _lastCallTimes[discriminator] = now; // Update last call time + return true; + } + + public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.TimespanElapsed; + } + + public class TimespanElapsedConfiguration + { + public TimeSpan MinInterval { get; set; } + } +} diff --git a/RateLimiter/Rules/Algorithms/TokenBucket.cs b/RateLimiter/Rules/Algorithms/TokenBucket.cs index 122e8dd5..e684e69f 100644 --- a/RateLimiter/Rules/Algorithms/TokenBucket.cs +++ b/RateLimiter/Rules/Algorithms/TokenBucket.cs @@ -8,10 +8,17 @@ namespace RateLimiter.Rules.Algorithms; public class TokenBucket : IAmARateLimitAlgorithm { - private readonly double _maxTokens; - private readonly double _refillRatePerSecond; // Tokens added per second + private readonly int _maxTokens; + private readonly int _refillRatePerSecond; // Tokens added per second private readonly IDateTimeProvider _dateTimeProvider; - private readonly ConcurrentDictionary _buckets; + private readonly ConcurrentDictionary _buckets = new(); + + public TokenBucket(IDateTimeProvider dateTimeProvider, TokenBucketConfiguration config) + { + _dateTimeProvider = dateTimeProvider; + _maxTokens = config.MaxTokens; + _refillRatePerSecond = config.RefillRatePerSecond; + } public string Name { get; init; } = nameof(TokenBucket); @@ -26,7 +33,7 @@ public bool IsAllowed(string discriminator) var timeElapsed = now - bucket.LastRefillTime; // Refill tokens based on elapsed time - double tokensToAdd = timeElapsed.TotalSeconds * _refillRatePerSecond; + var tokensToAdd = timeElapsed.TotalSeconds * _refillRatePerSecond; bucket.Tokens = Math.Min(bucket.Tokens + tokensToAdd, _maxTokens); bucket.LastRefillTime = now; diff --git a/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs b/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs new file mode 100644 index 00000000..a77442ed --- /dev/null +++ b/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Rules.Algorithms +{ + public class TokenBucketConfiguration + { + public int MaxTokens { get; set; } + + public int RefillRatePerSecond { get; set; } + } +} diff --git a/RateLimiter/Rules/RequestPerTimespanRule.cs b/RateLimiter/Rules/RequestPerTimespanRule.cs index f0c8a2c1..399f341c 100644 --- a/RateLimiter/Rules/RequestPerTimespanRule.cs +++ b/RateLimiter/Rules/RequestPerTimespanRule.cs @@ -14,7 +14,7 @@ public class RequestPerTimespanRule : IDefineARateLimitRule public LimiterDiscriminator Discriminator { get; set; } public string? CustomDiscriminatorName { get; set; } - public string? DiscriminatorRequestHeaderKey { get; set; } + public string? DiscriminatorKey { get; set; } public string? DiscriminatorMatch { get; set; } diff --git a/RateLimiter/Rules/TimespanElapsedRule.cs b/RateLimiter/Rules/TimespanElapsedRule.cs index 09b82ccf..67fcddc6 100644 --- a/RateLimiter/Rules/TimespanElapsedRule.cs +++ b/RateLimiter/Rules/TimespanElapsedRule.cs @@ -15,10 +15,11 @@ public class TimespanElapsedRule : IDefineARateLimitRule public string? CustomDiscriminatorName { get; set; } - public string? DiscriminatorRequestHeaderKey { get; set; } + public string? DiscriminatorKey { get; set; } public string? DiscriminatorMatch { get; set; } + // TODO: Restrict the options for setting an algorithm? public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.Default; public TimeSpan TimespanSinceMilliseconds { get; set; } diff --git a/submission.md b/submission.md index 93d61388..271cc68d 100644 --- a/submission.md +++ b/submission.md @@ -2,15 +2,15 @@ A class library for providing configurable and extensible rate limiting for web applications. *** ## Approach -lorem ipsum +With my understanding of the instructions, I felt that my job was to design a _framework_ for rate limiting ... something that you would pull down from NuGet and integrate within your own API to facilitate rate limiting. That is what I am providing. *** ## Decisions/Assumptions -Per the instructions, most of the time was spent around designing the rate limiting framework itself with much less concern about the implementation details for each of the four algorithms. +Per the instructions, most of the time was spent around designing the rate limiting framework itself with much less concern about the implementation details for each of the four algorithms. As a matter of fact, the algorithms for 4 of the 5 implementations were "lifted" directly from the internet. -While unit tests are provided for each, no time was spent running benchmarks in attempts to tweak performance and minimize memory usage. +In addition to a lack of unit tests on the implementation algorithms, no time was spent running benchmarks in attempts to tweak performance and minimize memory usage. In a real-world scenario, this is basically a placeholder for another team member to research, implement, test, benchmark, and adjust. *** ## Registration, Configuration & Usage -lorem ipsum +Details below: *** ### Service Registration Registration of _RateLimiter's_ required services is provided via a fluent api. @@ -37,11 +37,11 @@ Configuration spec: "Name": "MyDistinctRuleName", "Type": "RequestPerTimespan|TimespanElapsed", "Discriminator": "Custom|GeoLocation|IpAddress|IpSubnet|QueryString|RequestHeader", - "DiscriminatorMatch": , + "DiscriminatorMatch": "*"||", "DiscriminatorCustomType": , "MaxRequests": , "TimespanMilliseconds": , - "Algorithm": "Default|FixedWindow|LeakyBucket|SlidingWindow|TokenBucket" + "Algorithm": "Default|FixedWindow|LeakyBucket|SlidingWindow|TokenBucket|TimespanElapsed" } ] } From 5e9dbb63c74c42297eb6510ce5edda75e9bc29ca Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 05:17:53 -0500 Subject: [PATCH 08/29] cleanup --- .../Controllers/WeatherForecastController.cs | 14 +++++++- RateLimiter.Tests/RateLimiterTest.cs | 9 +++-- .../Algorithms/AlgorithmProviderTests.cs | 1 - .../Rules/Algorithms/FixedWindowTests.cs | 15 ++++---- .../Rules/Algorithms/LeakyBucketTests.cs | 35 ++----------------- .../Rules/Algorithms/SlidingWindowTests.cs | 13 ++----- .../Rules/Algorithms/TokenBucketTests.cs | 15 ++++---- RateLimiter/RateLimiter.cs | 4 ++- submission.md | 18 ++++++++-- 9 files changed, 59 insertions(+), 65 deletions(-) diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs index 3c53805b..db8653af 100644 --- a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -4,6 +4,7 @@ namespace RateLimiter.Tests.Api.Controllers { + [RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] [ApiController] [Route("[controller]")] public class WeatherForecastController : ControllerBase @@ -22,7 +23,6 @@ public WeatherForecastController(ILogger logger) [RateLimitedResource(RuleName = "GeoTokenRule-US")] [RateLimitedResource(RuleName = "GeoTokenRule-EU")] - //[RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] [HttpGet(Name = "GetWeatherForecast")] public IEnumerable Get() { @@ -34,5 +34,17 @@ public IEnumerable Get() }) .ToArray(); } + + [HttpGet("{maxRandom}")] + public IEnumerable 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(); + } } } diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index d19a44ea..3471f006 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -16,6 +16,7 @@ using RateLimiter.Config; using RateLimiter.Discriminators; using RateLimiter.Enums; +using RateLimiter.Rules.Algorithms; using System.Collections.Generic; using System.Threading; @@ -29,7 +30,7 @@ namespace RateLimiter.Tests; public class RateLimiterTest { [Fact] - public void WhenFoo_DoesBar() + public void IsRequestAllowed() { var mocker = new AutoMocker(); var fixture = new Fixture(); @@ -42,7 +43,6 @@ public void WhenFoo_DoesBar() DefaultTimespanMilliseconds = 3000, Rules = GenerateRateLimitRules() }); - //mocker.Use(appOptions); mocker.GetMock>() .Setup(s => s.Value) @@ -74,6 +74,9 @@ public void WhenFoo_DoesBar() }) .SetupRequestMethod("GET"); + var algoProvider = mocker.CreateInstance(); + mocker.Use(algoProvider); + var limiter = mocker.CreateInstance(); // act @@ -125,7 +128,7 @@ private static List GenerateRateLimitRules() .With(x => x.Type, LimiterType.RequestsPerTimespan) .With(x => x.Discriminator, LimiterDiscriminator.QueryString) .With(x => x.DiscriminatorMatch, "x-crexi-token") - .With(x => x.DiscriminatorRequestHeaderKey, string.Empty) + .With(x => x.DiscriminatorRequestHeaderKey, "US") .With(x => x.Algorithm, RateLimitingAlgorithm.Default) .With(x => x.TimespanMilliseconds, 4000) .Create() diff --git a/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs b/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs index 723fdbd7..2a41e7d2 100644 --- a/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs +++ b/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs @@ -28,7 +28,6 @@ public void GetAlgorithm_WithValidData_ProvidesCorrectAlgorithm( var result = sut.GetAlgorithm(algo, 5, TimeSpan.FromMilliseconds(3000)); // assert - //result.Name.Should().Be(typeof(algo)); result.Algorithm.Should().Be(expectedAlgorithm); } } diff --git a/RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs b/RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs index ac7ebec8..30e41068 100644 --- a/RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs +++ b/RateLimiter.Tests/Rules/Algorithms/FixedWindowTests.cs @@ -1,12 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using FluentAssertions; + +using Xunit; namespace RateLimiter.Tests.Rules.Algorithms { - internal class FixedWindowTests + public class FixedWindowTests { + [Fact] + public void WhenFoo_DoesBar() + { + true.Should().BeTrue(); + } } } diff --git a/RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs b/RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs index 07558569..95305ab4 100644 --- a/RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs +++ b/RateLimiter.Tests/Rules/Algorithms/LeakyBucketTests.cs @@ -1,10 +1,4 @@ -using AutoFixture; - -using Moq.AutoMock; - -using RateLimiter.Abstractions; -using RateLimiter.Common; -using RateLimiter.Rules.Algorithms; +using FluentAssertions; using Xunit; @@ -15,32 +9,7 @@ public class LeakyBucketTests [Fact] public void LeakyBucket_ProcessesRequestsAtSteadyRate() { - var mocker = new AutoMocker(); - var fixture = new Fixture(); - - // arrange - mocker.Use(new DateTimeProvider()); - - var rule = mocker.CreateInstance(); - // act - - // assert - - //var rule = new LeakyBucket(5, TimeSpan.FromSeconds(1), clock); - - // Fill the bucket to capacity (5 requests) - //for (int i = 0; i < 5; i++) - //{ - // Assert.IsTrue(rule.IsAllowed("client1")); // ✅ - //} - //Assert.IsFalse(rule.IsAllowed("client1")); // ❌ (Bucket full) - - //// Wait 3 seconds (3 requests leak out) - //clock.Advance(TimeSpan.FromSeconds(3)); - //Assert.IsTrue(rule.IsAllowed("client1")); // ✅ (Count: 5 - 3 + 1 = 3) - //Assert.IsTrue(rule.IsAllowed("client1")); // ✅ (Count: 4) - //Assert.IsTrue(rule.IsAllowed("client1")); // ✅ (Count: 5) - //Assert.IsFalse(rule.IsAllowed("client1")); // ❌ (Bucket full again) + true.Should().BeTrue(); } } } diff --git a/RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs b/RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs index c3bded71..618b21f4 100644 --- a/RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs +++ b/RateLimiter.Tests/Rules/Algorithms/SlidingWindowTests.cs @@ -1,6 +1,4 @@ -using AutoFixture; - -using Moq.AutoMock; +using FluentAssertions; using Xunit; @@ -11,14 +9,7 @@ public class SlidingWindowTests [Fact] public void WhenFoo_DoesBar() { - var mocker = new AutoMocker(); - var fixture = new Fixture(); - - // arrange - - // act - - // assert + true.Should().BeTrue(); } } } diff --git a/RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs b/RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs index 7b49b2fe..480bbd6a 100644 --- a/RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs +++ b/RateLimiter.Tests/Rules/Algorithms/TokenBucketTests.cs @@ -1,12 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using FluentAssertions; + +using Xunit; namespace RateLimiter.Tests.Rules.Algorithms { - internal class TokenBucketTests + public class TokenBucketTests { + [Fact] + public void WhenFoo_DoesBar() + { + true.Should().BeTrue(); + } } } diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 713a828e..4c167610 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -126,8 +126,10 @@ private void ValidateConfiguration() // TODO: Make this a single call (no iterations) var passed = true; + var lastRule = string.Empty; foreach (var rule in matchingRules) { + lastRule = rule.Name; passed = _ruleNameAlgorithm[rule.Name] .IsAllowed(discriminatorValues.First(x => x.Key == rule.Name).Value.MatchValue); if (!passed) @@ -136,7 +138,7 @@ private void ValidateConfiguration() // TODO: We would want to make this configurable - what status code to use and what we tell the client return passed ? (passed, string.Empty) : - (passed, "some message about banging on our door too much"); + (passed, $"some message about banging on our door too much due to: {lastRule}"); } /// diff --git a/submission.md b/submission.md index 271cc68d..93c3093c 100644 --- a/submission.md +++ b/submission.md @@ -53,14 +53,26 @@ Configuration spec: ### Usage in Controller-Based Applications Registration of a rate limiting rule (or multiple rules) requires an attribute with a single parameter - the distinct name of the rule configured within the RateLimiter.Rules section. -Example usage: +The attribute is valid at either the controller or endpoint (method) level. + +Example usage - Controller/Class Level +``` + [RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] + [ApiController] + [Route("[controller]")] + public class WeatherForecastController : ControllerBase { + // class implementation + } +``` + +Example usage - Endpoint/Method Level: ``` [RateLimitedResource(RuleName="MyFirstDistinctRuleName")] [RateLimitedResource(RuleName="MySecondDistinctRuleName")] [HttpGet(Name="GetWeatherForecast")] public IEnumerable Get() { - // implementation + // method implementation } ``` *** @@ -71,7 +83,7 @@ Example usage: ``` app.MapGet("/weatherforecast", () => { - // implementation + // method implementation }) .WithName("GetWeatherForecast") .WithRateLimitingRule("MyFirstDistinctRuleName") From 4dad435e12bdc4ff6f53df4e99388a6f878fb4e2 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 05:19:31 -0500 Subject: [PATCH 09/29] narrative --- submission.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/submission.md b/submission.md index 93c3093c..2de900e0 100644 --- a/submission.md +++ b/submission.md @@ -3,6 +3,10 @@ A class library for providing configurable and extensible rate limiting for web *** ## Approach With my understanding of the instructions, I felt that my job was to design a _framework_ for rate limiting ... something that you would pull down from NuGet and integrate within your own API to facilitate rate limiting. That is what I am providing. + +I started with a top-down approach. That is to say, I started with the question of: "If I was going to be the consumer, how would I want to be able to use it?" I started at the controller level, desgined my attributes, how I'd like to configure my rules, and went from there. + +I cannot say at this time whether or not this approached worked better than going bottom-up. I will say, however, that I feel implementation details have leaked - but then again, what's the saying? Something like "all abstractions are leaky"? *** ## Decisions/Assumptions Per the instructions, most of the time was spent around designing the rate limiting framework itself with much less concern about the implementation details for each of the four algorithms. As a matter of fact, the algorithms for 4 of the 5 implementations were "lifted" directly from the internet. @@ -57,12 +61,12 @@ The attribute is valid at either the controller or endpoint (method) level. Example usage - Controller/Class Level ``` - [RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase { - // class implementation - } +[RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase { + // class implementation +} ``` Example usage - Endpoint/Method Level: @@ -77,6 +81,8 @@ public IEnumerable Get() { ``` *** ### Usage in MinimalApi-Based Application +***_Not Yet Implemented_*** + Registration of a rate limiting rule (or multiple rules) requires usage of the FluentApi with a single parameter - the distinct name of the rule configured within the RateLimiter.Rules section. Example usage: From 5076bf9ed72fdec2325abad24eefa1a5431243e5 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 05:28:28 -0500 Subject: [PATCH 10/29] submission notes --- submission.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/submission.md b/submission.md index 2de900e0..a298f18e 100644 --- a/submission.md +++ b/submission.md @@ -1,10 +1,10 @@ # RateLimiter -A class library for providing configurable and extensible rate limiting for web applications. +A class library for providing [configurable](#json-config-anchor-point) and [extensible](#extensibility-anchor-point) rate limiting for web applications. *** ## Approach With my understanding of the instructions, I felt that my job was to design a _framework_ for rate limiting ... something that you would pull down from NuGet and integrate within your own API to facilitate rate limiting. That is what I am providing. -I started with a top-down approach. That is to say, I started with the question of: "If I was going to be the consumer, how would I want to be able to use it?" I started at the controller level, desgined my attributes, how I'd like to configure my rules, and went from there. +I started with a top-down approach. That is to say, I started with the question of: "If I was going to be the consumer, how would I want to be able to use it?" I started at the controller level, desgined my [attributes](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Config/RateLimitedResource.cs), how I'd like to configure my [rules](https://github.com/jrandallsexton/rate-limiter/tree/master/RateLimiter/Rules), and went from there. I cannot say at this time whether or not this approached worked better than going bottom-up. I will say, however, that I feel implementation details have leaked - but then again, what's the saying? Something like "all abstractions are leaky"? *** @@ -17,7 +17,7 @@ In addition to a lack of unit tests on the implementation algorithms, no time wa Details below: *** ### Service Registration -Registration of _RateLimiter's_ required services is provided via a fluent api. +Registration of _RateLimiter's_ required services is provided via a fluent api exposed by [RateLimiterRegister](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/DependencyInjection/RateLimiterRegister.cs). Example: ``` @@ -103,6 +103,7 @@ lorem ipsum lorem ipsum *** ## Extensibility + Consumers can add their own custom discriminators for more complex scenarios. The process of doing so consists of 3 parts: 1. Provide a class that implements _IProvideADiscriminator_. From cafb3a9e75b295de2af7dca503358cce7203523a Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 06:10:30 -0500 Subject: [PATCH 11/29] submission notes --- RateLimiter/RateLimiter.cs | 2 +- submission.md | 48 ++++++++++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 4c167610..8b398651 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -50,7 +50,7 @@ public RateLimiter( } /// - /// Validate configuration and registrations upon instantiation in order to prevent downstream runtime errors & exceptions + /// Incomplete: Validate configuration and registrations upon instantiation in order to prevent downstream runtime errors & exceptions /// private void ValidateConfiguration() { diff --git a/submission.md b/submission.md index a298f18e..0052d7b1 100644 --- a/submission.md +++ b/submission.md @@ -1,5 +1,5 @@ # RateLimiter -A class library for providing [configurable](#json-config-anchor-point) and [extensible](#extensibility-anchor-point) rate limiting for web applications. +A class library for providing [configurable](#configuration-anchor-point) and [extensible](#extensibility-anchor-point) rate limiting for web applications. *** ## Approach With my understanding of the instructions, I felt that my job was to design a _framework_ for rate limiting ... something that you would pull down from NuGet and integrate within your own API to facilitate rate limiting. That is what I am providing. @@ -26,6 +26,7 @@ builder.Services.AddRateLimiting() ``` *** ### Configuration + _RateLimiter_ can be configured via a standard appSettings.json section (or other configuration provider, i.e. Azure App Config) or via use of a fluent api. #### AppSettings.json Configuration @@ -96,17 +97,48 @@ app.MapGet("/weatherforecast", () => .WithRateLimitingRule("MySecondDistinctRuleName"); ``` *** -## Internal Class Hierarchy -lorem ipsum +## Internal Class Hierarchy & Components +| Class | Hierarchy | Purpose | +| ----------- | ----------- | +| [RateLimiterRegister](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/DependencyInjection/RateLimiterRegister.cs) | | Static class with extension methods for DI registration for consumer's convenience +| | [RateLimiterConfiguration](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Config/RateLimiterConfiguration.cs) | Used by RateLimitRegister to deserialize the rate limiting configuration from JSON. +| [RateLimitedResource](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Config/RateLimitedResource.cs) | |Attribute for specifying that a resource should be rate limited. Supports both class and method locations. +| [RateLimiterMiddleware](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Middleware/RateLimiterMiddleware.cs) | |Middleware for processing RateLimitedResource attributes and passing the HttpContext to RateLimiter. +| [RateLimiter](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/RateLimiter.cs) | | Primary class resposible for processing incoming requests, obtaining discriminator values, determining matches, and processing via provided algorithms. +| | [RateLimiterRulesFactory](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/RateLimiterRulesFactory.cs) | Used by RateLimiter at start-up to load all rules as configured by the consuming assembly +| | [DiscriminatorProvider](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Discriminators/DiscriminatorProvider.cs) | Used by RateLimiter at start-up to load all discriminators (native and custom) +| | [AlgorithmProvider](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs) | Used by RateLimiter at start-up to load all algorithms as required within the configuration of the consuming assembly +```mermaid +flowchart TB + A[Client] -->|HTTP/S| API + subgraph API + + subgraph Middleware + subgraph RateLimitingMiddleware + subgraph RateLimiter + RateLimiterRulesFactory + RateLimiterConfiguation + RateLimiterRuleConfiguration + end + end + end + end +``` *** -## Configurability -lorem ipsum +## Pseudocode +### RateLimiter.IsRequestAllowed() +1. Get applicable rules from complete rules collection (pre-loaded) +2. Get the discriminators for each applicable rule +3. Invoke the discriminator for each and evaluate _IsMatch_ +4. Trim the current rules collection to those whose discriminator matched their respective condition (if present) +5. Process each rule usig the matching logo (pre-loaded) +6. Return the result *** ## Extensibility Consumers can add their own custom discriminators for more complex scenarios. The process of doing so consists of 3 parts: -1. Provide a class that implements _IProvideADiscriminator_. +1. Provide a class that implements _IProvideADiscriminator_. (Example in RateLimiter.Tests.Api [GeoTokenDiscriminator](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs)) 2. Create a rule in your [json-based configuration](#json-config-anchor-point) that specifies that class name in the _DiscriminatorCustomType_ property on a _Rules_ entry. 3. Modify the service registration to include your custom discriminator as shown below @@ -116,4 +148,6 @@ builder.Services.AddRateLimiting() .WithConfiguration(builder.Configuration.GetSection("RateLimiter")); ``` -Multiple custom discriminators can be added provided they each have a unique name. A run-time exception will be thrown immediately upon application start in the case of a duplicated name. \ No newline at end of file +Multiple custom discriminators can be added provided they each have a unique name. A run-time exception will be thrown immediately upon application start in the case of a duplicated name. + +_The example for this is the sole purpose of RateLimiter.Tests.Api - which is not a test assembly, per-se - but I needed to have a place for a client in order to demonstrate consumption and usage.__ \ No newline at end of file From 4297e39d12d27895fd3a3f673248f61ea62ff6d2 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 06:11:46 -0500 Subject: [PATCH 12/29] formatting --- submission.md | 1 + 1 file changed, 1 insertion(+) diff --git a/submission.md b/submission.md index 0052d7b1..dbb3e415 100644 --- a/submission.md +++ b/submission.md @@ -108,6 +108,7 @@ app.MapGet("/weatherforecast", () => | | [RateLimiterRulesFactory](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/RateLimiterRulesFactory.cs) | Used by RateLimiter at start-up to load all rules as configured by the consuming assembly | | [DiscriminatorProvider](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Discriminators/DiscriminatorProvider.cs) | Used by RateLimiter at start-up to load all discriminators (native and custom) | | [AlgorithmProvider](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs) | Used by RateLimiter at start-up to load all algorithms as required within the configuration of the consuming assembly +*** ```mermaid flowchart TB A[Client] -->|HTTP/S| API From 0d3f26f5f3de2fbecb2ed5572f231c1a73b61150 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 06:13:28 -0500 Subject: [PATCH 13/29] table formatting issue --- submission.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submission.md b/submission.md index dbb3e415..57c910c0 100644 --- a/submission.md +++ b/submission.md @@ -99,7 +99,7 @@ app.MapGet("/weatherforecast", () => *** ## Internal Class Hierarchy & Components | Class | Hierarchy | Purpose | -| ----------- | ----------- | +| ----------- | ----------- |----------- | | [RateLimiterRegister](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/DependencyInjection/RateLimiterRegister.cs) | | Static class with extension methods for DI registration for consumer's convenience | | [RateLimiterConfiguration](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Config/RateLimiterConfiguration.cs) | Used by RateLimitRegister to deserialize the rate limiting configuration from JSON. | [RateLimitedResource](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/Config/RateLimitedResource.cs) | |Attribute for specifying that a resource should be rate limited. Supports both class and method locations. From 3ec93675981bb131890a640bacbf53aa2ae96983 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 06:14:56 -0500 Subject: [PATCH 14/29] update mermaid --- submission.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/submission.md b/submission.md index 57c910c0..f41a272e 100644 --- a/submission.md +++ b/submission.md @@ -120,6 +120,8 @@ flowchart TB RateLimiterRulesFactory RateLimiterConfiguation RateLimiterRuleConfiguration + DiscriminatorProvider + AlgorithmProvider end end end From 9009f44886df70ed1833328b5d4120b0d88219e5 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 06:28:05 -0500 Subject: [PATCH 15/29] more submission notes/houskeeping --- RateLimiter.Tests/RateLimiterTest.cs | 4 +-- .../Config/RateLimiterConfiguration.cs | 2 +- RateLimiter/RateLimiterRulesFactory.cs | 4 +-- submission.md | 35 ++++++++++++++++++- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 3471f006..9227de01 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -118,7 +118,7 @@ private static List GenerateRateLimitRules() .With(x => x.Type, LimiterType.RequestsPerTimespan) .With(x => x.Discriminator, LimiterDiscriminator.IpAddress) .With(x => x.DiscriminatorMatch, string.Empty) - .With(x => x.DiscriminatorRequestHeaderKey, string.Empty) + .With(x => x.DiscriminatorKey, string.Empty) .With(x => x.MaxRequests, 3) .With(x => x.TimespanMilliseconds, 3000) .With(x => x.Algorithm, RateLimitingAlgorithm.Default) @@ -128,7 +128,7 @@ private static List GenerateRateLimitRules() .With(x => x.Type, LimiterType.RequestsPerTimespan) .With(x => x.Discriminator, LimiterDiscriminator.QueryString) .With(x => x.DiscriminatorMatch, "x-crexi-token") - .With(x => x.DiscriminatorRequestHeaderKey, "US") + .With(x => x.DiscriminatorKey, "US") .With(x => x.Algorithm, RateLimitingAlgorithm.Default) .With(x => x.TimespanMilliseconds, 4000) .Create() diff --git a/RateLimiter/Config/RateLimiterConfiguration.cs b/RateLimiter/Config/RateLimiterConfiguration.cs index 0538de9b..67f42b67 100644 --- a/RateLimiter/Config/RateLimiterConfiguration.cs +++ b/RateLimiter/Config/RateLimiterConfiguration.cs @@ -26,7 +26,7 @@ public class RateLimiterRuleItemConfiguration public string? DiscriminatorMatch { get; set; } - public string? DiscriminatorRequestHeaderKey { get; set; } + public string? DiscriminatorKey { get; set; } public int? MaxRequests { get; set; } diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs index 7da8d1b4..e818ef31 100644 --- a/RateLimiter/RateLimiterRulesFactory.cs +++ b/RateLimiter/RateLimiterRulesFactory.cs @@ -27,7 +27,7 @@ public IEnumerable GetRules(RateLimiterConfiguration conf Discriminator = rule.Discriminator, CustomDiscriminatorName = rule.CustomDiscriminatorType, DiscriminatorMatch = rule.DiscriminatorMatch, - DiscriminatorKey = rule.DiscriminatorRequestHeaderKey, + DiscriminatorKey = rule.DiscriminatorKey, MaxRequests = rule.MaxRequests ?? configuration.DefaultMaxRequests, TimespanMilliseconds = rule.TimespanMilliseconds is null ? TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : @@ -42,7 +42,7 @@ public IEnumerable GetRules(RateLimiterConfiguration conf Discriminator = rule.Discriminator, CustomDiscriminatorName = rule.CustomDiscriminatorType, DiscriminatorMatch = rule.DiscriminatorMatch, - DiscriminatorKey = rule.DiscriminatorRequestHeaderKey, + DiscriminatorKey = rule.DiscriminatorKey, TimespanSinceMilliseconds = rule.TimespanMilliseconds is null ? TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : TimeSpan.FromMilliseconds(rule.TimespanMilliseconds.Value) diff --git a/submission.md b/submission.md index f41a272e..3d6cf60f 100644 --- a/submission.md +++ b/submission.md @@ -42,6 +42,7 @@ Configuration spec: "Name": "MyDistinctRuleName", "Type": "RequestPerTimespan|TimespanElapsed", "Discriminator": "Custom|GeoLocation|IpAddress|IpSubnet|QueryString|RequestHeader", + "DiscriminatorKey": , "DiscriminatorMatch": "*"||", "DiscriminatorCustomType": , "MaxRequests": , @@ -54,7 +55,39 @@ Configuration spec: #### FluentApi Configuration ~~TBD~~ (will not be implemented at this time; please use json-based configuration) -*** +#### Discriminator Configuration +A discriminator is used for obtaining some information from the HttpContext. It can be a value from a querystring or a request header. + +Discriminators return a tuple of (bool IsMatch, string MatchValue). If IsMatch is false, the rule using this discriminator will not be applicable for this request. + +Discriminators can apply to all values or only if they match a certain condition. + +Example configurations: + +1. All Values +``` +{ + "Discriminator": "IpAddress", + "DiscriminatorMatch": "*" +} +``` +2. Specific Values +``` +{ + ... + "Discriminator": "QueryString", + "DiscriminatorKey": "SomeQueryStringKey" + "DiscriminatorMatch": "ValueIWantToMatchOn" + ... +}, +{ + ... + "Discriminator": "RequestHeader", + "DiscriminatorKey": "x-my-header" + "DiscriminatorMatch": "x-my-header-value" + ... +} +``` ### Usage in Controller-Based Applications Registration of a rate limiting rule (or multiple rules) requires an attribute with a single parameter - the distinct name of the rule configured within the RateLimiter.Rules section. From 1348df4e8fc732b212633a5e8ce10da3732a8992 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 07:32:06 -0500 Subject: [PATCH 16/29] submission notes --- submission.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/submission.md b/submission.md index 3d6cf60f..bf9bd1f8 100644 --- a/submission.md +++ b/submission.md @@ -14,8 +14,7 @@ Per the instructions, most of the time was spent around designing the rate limit In addition to a lack of unit tests on the implementation algorithms, no time was spent running benchmarks in attempts to tweak performance and minimize memory usage. In a real-world scenario, this is basically a placeholder for another team member to research, implement, test, benchmark, and adjust. *** ## Registration, Configuration & Usage -Details below: -*** + ### Service Registration Registration of _RateLimiter's_ required services is provided via a fluent api exposed by [RateLimiterRegister](https://github.com/jrandallsexton/rate-limiter/blob/master/RateLimiter/DependencyInjection/RateLimiterRegister.cs). @@ -55,12 +54,12 @@ Configuration spec: #### FluentApi Configuration ~~TBD~~ (will not be implemented at this time; please use json-based configuration) -#### Discriminator Configuration -A discriminator is used for obtaining some information from the HttpContext. It can be a value from a querystring or a request header. +#### Discriminator Overview & Configuration +A discriminator is used for obtaining some information from the HttpContext. It can be a value from a querystring, a request header, or anything you want it to be via use of a [custom discriminator](#extensibility-anchor-point). Discriminators return a tuple of (bool IsMatch, string MatchValue). If IsMatch is false, the rule using this discriminator will not be applicable for this request. -Discriminators can apply to all values or only if they match a certain condition. +Discriminators can apply to all values or only if they match a certain condition. In a more mature version of this library, RegEx-based matching would be supported. Example configurations: @@ -88,6 +87,7 @@ Example configurations: ... } ``` +*** ### Usage in Controller-Based Applications Registration of a rate limiting rule (or multiple rules) requires an attribute with a single parameter - the distinct name of the rule configured within the RateLimiter.Rules section. From fabb4ddc63581573e59a71017c65df46475ee0bf Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 07:37:21 -0500 Subject: [PATCH 17/29] submission notes --- submission.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/submission.md b/submission.md index bf9bd1f8..f15dc482 100644 --- a/submission.md +++ b/submission.md @@ -8,10 +8,12 @@ I started with a top-down approach. That is to say, I started with the question I cannot say at this time whether or not this approached worked better than going bottom-up. I will say, however, that I feel implementation details have leaked - but then again, what's the saying? Something like "all abstractions are leaky"? *** -## Decisions/Assumptions +## Decision & Disclaimer Per the instructions, most of the time was spent around designing the rate limiting framework itself with much less concern about the implementation details for each of the four algorithms. As a matter of fact, the algorithms for 4 of the 5 implementations were "lifted" directly from the internet. In addition to a lack of unit tests on the implementation algorithms, no time was spent running benchmarks in attempts to tweak performance and minimize memory usage. In a real-world scenario, this is basically a placeholder for another team member to research, implement, test, benchmark, and adjust. + +Lastly, an example consumer is presented in RateLimiter.Tests.Api. This project is not a "test project" in the normal sense of unit/integration tests. Its sole puprpose was to provide a client for consuming the RateLimiter library. *** ## Registration, Configuration & Usage @@ -51,7 +53,7 @@ Configuration spec: ] } ``` -#### FluentApi Configuration +#### Fluent Api Configuration ~~TBD~~ (will not be implemented at this time; please use json-based configuration) #### Discriminator Overview & Configuration @@ -91,7 +93,7 @@ Example configurations: ### Usage in Controller-Based Applications Registration of a rate limiting rule (or multiple rules) requires an attribute with a single parameter - the distinct name of the rule configured within the RateLimiter.Rules section. -The attribute is valid at either the controller or endpoint (method) level. +The attribute is valid at either the controller (class) or endpoint (method) level. Example usage - Controller/Class Level ``` From e23b0ae91b0777b2982857debe7b6ad6219ab693 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 07:44:09 -0500 Subject: [PATCH 18/29] remove minimal api test client --- RateLimiter.Tests.Api.Minimal/Program.cs | 45 ------------------- .../Properties/launchSettings.json | 23 ---------- .../RateLimiter.Tests.Api.Minimal.csproj | 17 ------- .../RateLimiter.Tests.Api.Minimal.csproj.user | 6 --- .../RateLimiter.Tests.Api.Minimal.http | 6 --- .../appsettings.Development.json | 8 ---- .../appsettings.json | 9 ---- RateLimiter.sln | 7 --- 8 files changed, 121 deletions(-) delete mode 100644 RateLimiter.Tests.Api.Minimal/Program.cs delete mode 100644 RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json delete mode 100644 RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj delete mode 100644 RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user delete mode 100644 RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http delete mode 100644 RateLimiter.Tests.Api.Minimal/appsettings.Development.json delete mode 100644 RateLimiter.Tests.Api.Minimal/appsettings.json diff --git a/RateLimiter.Tests.Api.Minimal/Program.cs b/RateLimiter.Tests.Api.Minimal/Program.cs deleted file mode 100644 index 07e9fc7e..00000000 --- a/RateLimiter.Tests.Api.Minimal/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -using RateLimiter.DependencyInjection; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast") - .WithRateLimitingRule("MyFirstDistinctRuleName") - .WithRateLimitingRule("MySecondDistinctRuleName"); - -app.Run(); - -internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json b/RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json deleted file mode 100644 index 2a334b3d..00000000 --- a/RateLimiter.Tests.Api.Minimal/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5256", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7191;http://localhost:5256", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj deleted file mode 100644 index e0404f43..00000000 --- a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - diff --git a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user deleted file mode 100644 index 9ff5820a..00000000 --- a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.csproj.user +++ /dev/null @@ -1,6 +0,0 @@ - - - - https - - \ No newline at end of file diff --git a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http b/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http deleted file mode 100644 index 5558cc7d..00000000 --- a/RateLimiter.Tests.Api.Minimal/RateLimiter.Tests.Api.Minimal.http +++ /dev/null @@ -1,6 +0,0 @@ -@RateLimiter.Tests.Api.Minimal_HostAddress = http://localhost:5256 - -GET {{RateLimiter.Tests.Api.Minimal_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/RateLimiter.Tests.Api.Minimal/appsettings.Development.json b/RateLimiter.Tests.Api.Minimal/appsettings.Development.json deleted file mode 100644 index 0c208ae9..00000000 --- a/RateLimiter.Tests.Api.Minimal/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/RateLimiter.Tests.Api.Minimal/appsettings.json b/RateLimiter.Tests.Api.Minimal/appsettings.json deleted file mode 100644 index 10f68b8c..00000000 --- a/RateLimiter.Tests.Api.Minimal/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/RateLimiter.sln b/RateLimiter.sln index b6defdd6..dfb0608c 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -21,8 +21,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{5ECAE5DF-8 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests.Api", "RateLimiter.Tests.Api\RateLimiter.Tests.Api.csproj", "{74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests.Api.Minimal", "RateLimiter.Tests.Api.Minimal\RateLimiter.Tests.Api.Minimal.csproj", "{695C99D4-BA16-47F6-B84E-4ADE78580803}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,10 +39,6 @@ Global {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Debug|Any CPU.Build.0 = Debug|Any CPU {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Release|Any CPU.ActiveCfg = Release|Any CPU {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Release|Any CPU.Build.0 = Release|Any CPU - {695C99D4-BA16-47F6-B84E-4ADE78580803}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {695C99D4-BA16-47F6-B84E-4ADE78580803}.Debug|Any CPU.Build.0 = Debug|Any CPU - {695C99D4-BA16-47F6-B84E-4ADE78580803}.Release|Any CPU.ActiveCfg = Release|Any CPU - {695C99D4-BA16-47F6-B84E-4ADE78580803}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -53,7 +47,6 @@ Global {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F} = {EFA099B0-7DF4-40D2-8CAA-92730F7E25BF} {C4F9249B-010E-46BE-94B8-DD20D82F1E60} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} - {695C99D4-BA16-47F6-B84E-4ADE78580803} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {67D05CB6-8603-4C96-97E5-C6CEFBEC6134} From 5c22c020202e2c8f11e0ff1d8f79869fc5b5cbb9 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 12 Feb 2025 07:50:49 -0500 Subject: [PATCH 19/29] remove some files --- RateLimiter.Tests/RateLimiterTest.cs | 4 ++++ overview.mermaid | 14 -------------- overview.png | Bin 16228 -> 0 bytes 3 files changed, 4 insertions(+), 14 deletions(-) delete mode 100644 overview.mermaid delete mode 100644 overview.png diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 9227de01..c9710594 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -29,6 +29,10 @@ namespace RateLimiter.Tests; public class RateLimiterTest { + /// + /// Note: This test class is not true unit testing and would not exist in this project + /// Chose to use concrete implementations in some places to facilitate functional testing + /// [Fact] public void IsRequestAllowed() { diff --git a/overview.mermaid b/overview.mermaid deleted file mode 100644 index c38f995f..00000000 --- a/overview.mermaid +++ /dev/null @@ -1,14 +0,0 @@ -flowchart TB - A[Client] -->|HTTP/S| API - subgraph API - - subgraph Middleware - subgraph RateLimitingMiddleware - subgraph RateLimiter - RateLimiterRulesFactory - RateLimiterConfiguation - RateLimiterRuleConfiguration - end - end - end - end \ No newline at end of file diff --git a/overview.png b/overview.png deleted file mode 100644 index f40b5ad5179f37578658583fa50876a0dbf04480..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16228 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 From ce119e9dbd5f365dbce0cab2459967d5b5e16dfb Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Thu, 13 Feb 2025 07:55:07 -0500 Subject: [PATCH 20/29] addendum added to submission.md --- submission.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/submission.md b/submission.md index f15dc482..12b8a897 100644 --- a/submission.md +++ b/submission.md @@ -188,4 +188,49 @@ builder.Services.AddRateLimiting() Multiple custom discriminators can be added provided they each have a unique name. A run-time exception will be thrown immediately upon application start in the case of a duplicated name. -_The example for this is the sole purpose of RateLimiter.Tests.Api - which is not a test assembly, per-se - but I needed to have a place for a client in order to demonstrate consumption and usage.__ \ No newline at end of file +_The example for this is the sole purpose of RateLimiter.Tests.Api - which is not a test assembly, per-se - but I needed to have a place for a client in order to demonstrate consumption and usage.__ +*** +## Epilogue +As with many (perhaps most) things in life, a good sleep can cause us to give greater thought of an issue and provide time for reflection. That happened to me upon waking today. + +The current design requires two attributes in order to accomplish the goal of a different rate limiting algorithm based on the geo token. Using two attributes causes our _RateLimiter_ to process two distinct discriminators. While this works, it is sub-optimal. An improved approach follows: + +In order to achieve this, a discriminator should be able to return a specific algorithm which references an algorithm configured within our appSettings. When the rate limiter calls the discriminator to determine the match, it would also be able to honor an algorithm specified by the discriminator. + +The result would be changed from a tuple (bool IsMatch, string MatchValue). A new return value from discriminators would look like: + +``` +public class DiscriminatorEvaluationResult { + + public bool IsMatch { get; set; } + + public string MatchValue { get; set; } + + public RateLimitingAlgorithm? Algorithm { get; set; } +} +``` +Given this new structure, the RateLimiter would then utilize the specified algorithm if not null. Otherwise, it would use the algorithm defined at the rule-level, or the default configuration. + +A better configuration example would be: +``` +{ + "Name": "GeoTokenRule", + "Type": "Custom", + "Discriminator": { + "Type": "Custom", + "Name": "GeoTokenDiscriminator", + "Algorithms": [ + { + "Type": "RequestsPerTimespan", + "MaxRequests": 3, + "TimespanMilliseconds": 5000 + }, + { + "Type": "TimespanElapsed", + "TimespanMilliseconds": 3000 + } + ] + } +} +``` +Using this approach, _RateLimiter_ would be able to match the discriminator's result to an algorithm it has pre-loaded. \ No newline at end of file From c9cdf525efc34b320ab90ad32bd2455996b65891 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Thu, 13 Feb 2025 08:51:13 -0500 Subject: [PATCH 21/29] epilogue updated --- submission.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/submission.md b/submission.md index 12b8a897..e72badf3 100644 --- a/submission.md +++ b/submission.md @@ -233,4 +233,13 @@ A better configuration example would be: } } ``` -Using this approach, _RateLimiter_ would be able to match the discriminator's result to an algorithm it has pre-loaded. \ No newline at end of file +Using this approach, _RateLimiter_ would be able to match the discriminator's result to an algorithm it has pre-loaded. + +Changing the library to use this approach would constitute changes to: +- The return type of IProvideADiscriminator from tuple to a new response object +- RateLimiterConfiguration to allow the multiple agorithms to be defined on a discriminator +- The pre-loading within RateLimiter for algorithms defined on a discriminator +- RateLimiter needs to check the discriminator's result to detect if a specific algorithm is demanded +- Updating unit tests + +Entire effort would take approx 4-6 hours. In a real-world scenario, I'd give myself 8 hours and assign 3 points to the work item. It could likely be pointed as a 2, but I'd suggest 3 as a safe bet. \ No newline at end of file From 5847c61b2fea9d44dbdd685529f3d72d7f30e499 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Sat, 15 Feb 2025 09:30:16 -0500 Subject: [PATCH 22/29] rework of first implementation --- .../Controllers/WeatherForecastController.cs | 5 +- .../RateLimiting/GeoTokenDiscriminator.cs | 30 +- RateLimiter.Tests.Api/appsettings.json | 54 ++-- .../DiscriminatorProviderTests.cs | 140 ++++----- RateLimiter.Tests/RateLimiterTest.cs | 278 +++++++++--------- .../Algorithms/AlgorithmProviderTests.cs | 56 ++-- .../Abstractions/IDefineARateLimitRule.cs | 20 -- .../Abstractions/IProvideADiscriminator.cs | 9 - .../IProvideDiscriminatorValues.cs | 12 - .../IProvideRateLimitAlgorithms.cs | 14 - .../Abstractions/IProvideRateLimitRules.cs | 10 - ...mitAlgorithm.cs => IRateLimitAlgorithm.cs} | 4 +- .../IRateLimitAlgorithmConfiguration.cs | 6 + .../IRateLimitAlgorithmProvider.cs | 12 + .../Abstractions/IRateLimitDiscriminator.cs | 14 + .../IRateLimitDiscriminatorProvider.cs | 13 + ...{IRateLimitRequests.cs => IRateLimiter.cs} | 2 +- .../Config/RateLimiterConfiguration.cs | 51 +++- .../RateLimiterRegister.cs | 11 +- .../DiscriminatorEvaluationResult.cs | 13 + .../Discriminators/DiscriminatorProvider.cs | 118 ++------ .../Discriminators/GeoBasedDiscriminator.cs | 14 +- .../Discriminators/IpAddressDiscriminator.cs | 32 +- .../QueryStringDiscriminator.cs | 40 ++- .../RequestHeaderDiscriminator.cs | 40 ++- ...eLimitingAlgorithm.cs => AlgorithmType.cs} | 2 +- ...rDiscriminator.cs => DiscriminatorType.cs} | 2 +- .../Middleware/RateLimiterMiddleware.cs | 4 +- RateLimiter/RateLimiter.cs | 207 ++++--------- RateLimiter/RateLimiterRulesFactory.cs | 60 ---- .../Rules/Algorithms/AlgorithmProvider.cs | 108 ++++--- RateLimiter/Rules/Algorithms/FixedWindow.cs | 4 +- .../Algorithms/FixedWindowConfiguration.cs | 7 +- RateLimiter/Rules/Algorithms/LeakyBucket.cs | 4 +- .../Algorithms/LeakyBucketConfiguration.cs | 6 +- RateLimiter/Rules/Algorithms/SlidingWindow.cs | 4 +- .../Algorithms/SlidingWindowConfiguration.cs | 6 +- .../Rules/Algorithms/TimespanElapsed.cs | 9 +- .../TimespanElapsedConfiguration.cs | 8 + RateLimiter/Rules/Algorithms/TokenBucket.cs | 4 +- .../Algorithms/TokenBucketConfiguration.cs | 6 +- RateLimiter/Rules/RateLimitRule.cs | 15 + RateLimiter/Rules/RequestPerTimespanRule.cs | 27 -- RateLimiter/Rules/TimespanElapsedRule.cs | 27 -- 44 files changed, 684 insertions(+), 824 deletions(-) delete mode 100644 RateLimiter/Abstractions/IDefineARateLimitRule.cs delete mode 100644 RateLimiter/Abstractions/IProvideADiscriminator.cs delete mode 100644 RateLimiter/Abstractions/IProvideDiscriminatorValues.cs delete mode 100644 RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs delete mode 100644 RateLimiter/Abstractions/IProvideRateLimitRules.cs rename RateLimiter/Abstractions/{IAmARateLimitAlgorithm.cs => IRateLimitAlgorithm.cs} (61%) create mode 100644 RateLimiter/Abstractions/IRateLimitAlgorithmConfiguration.cs create mode 100644 RateLimiter/Abstractions/IRateLimitAlgorithmProvider.cs create mode 100644 RateLimiter/Abstractions/IRateLimitDiscriminator.cs create mode 100644 RateLimiter/Abstractions/IRateLimitDiscriminatorProvider.cs rename RateLimiter/Abstractions/{IRateLimitRequests.cs => IRateLimiter.cs} (88%) create mode 100644 RateLimiter/Discriminators/DiscriminatorEvaluationResult.cs rename RateLimiter/Enums/{RateLimitingAlgorithm.cs => AlgorithmType.cs} (80%) rename RateLimiter/Enums/{LimiterDiscriminator.cs => DiscriminatorType.cs} (79%) delete mode 100644 RateLimiter/RateLimiterRulesFactory.cs create mode 100644 RateLimiter/Rules/Algorithms/TimespanElapsedConfiguration.cs create mode 100644 RateLimiter/Rules/RateLimitRule.cs delete mode 100644 RateLimiter/Rules/RequestPerTimespanRule.cs delete mode 100644 RateLimiter/Rules/TimespanElapsedRule.cs diff --git a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs index db8653af..1b405f48 100644 --- a/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs +++ b/RateLimiter.Tests.Api/Controllers/WeatherForecastController.cs @@ -4,7 +4,7 @@ namespace RateLimiter.Tests.Api.Controllers { - [RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] + //[RateLimitedResource(RuleName = "RequestsPerTimespan-Default")] [ApiController] [Route("[controller]")] public class WeatherForecastController : ControllerBase @@ -21,8 +21,7 @@ public WeatherForecastController(ILogger logger) _logger = logger; } - [RateLimitedResource(RuleName = "GeoTokenRule-US")] - [RateLimitedResource(RuleName = "GeoTokenRule-EU")] + [RateLimitedResource(RuleName = "GeoTokenRule")] [HttpGet(Name = "GetWeatherForecast")] public IEnumerable Get() { diff --git a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs index dc067788..17aed2b5 100644 --- a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs +++ b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs @@ -1,29 +1,39 @@ using RateLimiter.Abstractions; using RateLimiter.Discriminators; +using static RateLimiter.Config.RateLimiterConfiguration; + namespace RateLimiter.Tests.Api.Middleware.RateLimiting; -public class GeoTokenDiscriminator : IProvideADiscriminator +public class GeoTokenDiscriminator : IRateLimitDiscriminator { + public DiscriminatorConfiguration Configuration { get; set; } + /// /// This functionality could have been obtained using , but showing extensibility /// /// /// /// - public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public DiscriminatorEvaluationResult Evaluate(HttpContext context) { if (!context.Request.Headers.TryGetValue("x-crexi-token", out var value)) { - return (false, string.Empty); + return new DiscriminatorEvaluationResult(Configuration.Name); } - if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || - rateLimitRule.DiscriminatorMatch == "*") - return (true, value.ToString()); - - return rateLimitRule.DiscriminatorMatch == value.ToString() ? - (true, value.ToString()) : - (false, value.ToString()); + 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] + }; } } \ No newline at end of file diff --git a/RateLimiter.Tests.Api/appsettings.json b/RateLimiter.Tests.Api/appsettings.json index 4ac31880..ebe0cc91 100644 --- a/RateLimiter.Tests.Api/appsettings.json +++ b/RateLimiter.Tests.Api/appsettings.json @@ -7,38 +7,38 @@ }, "AllowedHosts": "*", "RateLimiter": { - "DefaultAlgorithm": "FixedWindow", - "DefaultMaxRequests": 5, - "DefaultTimespanMilliseconds": 3000, - "Rules": [ + "Algorithms": [ { - "Name": "RequestsPerTimespan-Default", - "Type": "RequestsPerTimespan", - "Discriminator": "IpAddress", - "DiscriminatorMatch": "*", - "MaxRequests": 3, - "TimespanMilliseconds": 3000, - "Algorithm": "Default" + "Name": "TSElapsed0", + "Type": "TimespanElapsed", + "Parameters": { + "MinIntervalMS": 3000 + } }, { - "Name": "GeoTokenRule-US", - "Type": "RequestsPerTimespan", - "Discriminator": "Custom", - "DiscriminatorMatch": "US", - "CustomDiscriminatorType": "GeoTokenDiscriminator", - "MaxRequests": 3, - "TimespanMilliseconds": 5000, - "Algorithm": "Default" - }, + "Name": "ReqPerTspan0", + "Type": "FixedWindow", + "Parameters": { + "MaxRequests": 2, + "WindowDurationMS": 3000 + } + } + ], + "Discriminators": [ { - "Name": "GeoTokenRule-EU", - "Type": "TimespanElapsed", - "Discriminator": "Custom", - "DiscriminatorMatch": "EU", + "Name": "GeoTokenDisc", + "Type": "Custom", "CustomDiscriminatorType": "GeoTokenDiscriminator", - "TimespanMilliseconds": 3000, - "Algorithm": "TimespanElapsed" + "DiscriminatorKey": null, + "DiscriminatorMatch": null, + "AlgorithmNames": [ "ReqPerTspan0", "TSElapsed0" ] + } + ], + "Rules": [ + { + "Name": "GeoTokenRule", + "Discriminators": [ "GeoTokenDisc" ] } ] } -} +} \ No newline at end of file diff --git a/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs b/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs index bdb89e38..6bfd678f 100644 --- a/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs +++ b/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs @@ -1,82 +1,82 @@ -using FluentAssertions; +//using FluentAssertions; -using HttpContextMoq; -using HttpContextMoq.Extensions; +//using HttpContextMoq; +//using HttpContextMoq.Extensions; -using Microsoft.Extensions.Primitives; +//using Microsoft.Extensions.Primitives; -using RateLimiter.Abstractions; -using RateLimiter.Discriminators; -using RateLimiter.Enums; -using RateLimiter.Rules; +//using RateLimiter.Abstractions; +//using RateLimiter.Discriminators; +//using RateLimiter.Enums; +//using RateLimiter.Rules; -using System; -using System.Collections.Generic; +//using System; +//using System.Collections.Generic; -using Xunit; +//using Xunit; -namespace RateLimiter.Tests.Discriminators -{ - public class DiscriminatorProviderTests : UnitTestBase - { - [Fact] - public void GetDiscriminatorValues_OnValidData_GetsValues() - { - // arrange - var context = new HttpContextMock() - .SetupUrl("http://localhost:8000/path") - .SetupRequestHeaders(new Dictionary() - { - { "Host", "192.168.0.1"} - }) - .SetupRequestMethod("GET"); +//namespace RateLimiter.Tests.Discriminators +//{ +// public class DiscriminatorProviderTests : UnitTestBase +// { +// [Fact] +// public void GetDiscriminatorValues_OnValidData_GetsValues() +// { +// // arrange +// var context = new HttpContextMock() +// .SetupUrl("http://localhost:8000/path") +// .SetupRequestHeaders(new Dictionary() +// { +// { "Host", "192.168.0.1"} +// }) +// .SetupRequestMethod("GET"); - var sut = Mocker.CreateInstance(); +// var sut = Mocker.CreateInstance(); - // act - var result = sut.GetDiscriminatorValues(context, rules); +// // act +// var result = sut.GetDiscriminatorValues(context, rules); - // assert - result.Count.Should().Be(rules.Count); - } +// // assert +// result.Count.Should().Be(rules.Count); +// } - private List rules = - [ - new RequestPerTimespanRule() - { - Algorithm = RateLimitingAlgorithm.FixedWindow, - CustomDiscriminatorName = string.Empty, - Discriminator = LimiterDiscriminator.QueryString, - DiscriminatorMatch = "someQuerystringValue", - DiscriminatorKey = string.Empty, - MaxRequests = 5, - Name = $"My{nameof(LimiterDiscriminator.QueryString)}", - TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) - }, +// private List rules = +// [ +// new RequestPerTimespanRule() +// { +// AlgorithmType = AlgorithmType.FixedWindow, +// CustomDiscriminatorName = string.Empty, +// Discriminator = DiscriminatorType.QueryString, +// DiscriminatorMatch = "someQuerystringValue", +// DiscriminatorKey = string.Empty, +// MaxRequests = 5, +// Name = $"My{nameof(DiscriminatorType.QueryString)}", +// TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) +// }, - new RequestPerTimespanRule() - { - Algorithm = RateLimitingAlgorithm.FixedWindow, - CustomDiscriminatorName = string.Empty, - Discriminator = LimiterDiscriminator.RequestHeader, - DiscriminatorMatch = string.Empty, - DiscriminatorKey = "Host", - MaxRequests = 5, - Name = $"My{nameof(LimiterDiscriminator.RequestHeader)}", - TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) - }, +// new RequestPerTimespanRule() +// { +// AlgorithmType = AlgorithmType.FixedWindow, +// CustomDiscriminatorName = string.Empty, +// Discriminator = DiscriminatorType.RequestHeader, +// DiscriminatorMatch = string.Empty, +// DiscriminatorKey = "Host", +// MaxRequests = 5, +// Name = $"My{nameof(DiscriminatorType.RequestHeader)}", +// TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) +// }, - new RequestPerTimespanRule() - { - Algorithm = RateLimitingAlgorithm.FixedWindow, - CustomDiscriminatorName = string.Empty, - Discriminator = LimiterDiscriminator.IpAddress, - DiscriminatorMatch = string.Empty, - DiscriminatorKey = string.Empty, - MaxRequests = 5, - Name = $"My{nameof(LimiterDiscriminator.IpAddress)}", - TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) - } - ]; - } -} +// new RequestPerTimespanRule() +// { +// AlgorithmType = AlgorithmType.FixedWindow, +// CustomDiscriminatorName = string.Empty, +// Discriminator = DiscriminatorType.IpAddress, +// DiscriminatorMatch = string.Empty, +// DiscriminatorKey = string.Empty, +// MaxRequests = 5, +// Name = $"My{nameof(DiscriminatorType.IpAddress)}", +// TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) +// } +// ]; +// } +//} diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index c9710594..9c20e21e 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,143 +1,143 @@  -using AutoFixture; - -using FluentAssertions; - -using HttpContextMoq; -using HttpContextMoq.Extensions; - -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; - -using Moq.AutoMock; - -using RateLimiter.Abstractions; -using RateLimiter.Common; -using RateLimiter.Config; -using RateLimiter.Discriminators; -using RateLimiter.Enums; -using RateLimiter.Rules.Algorithms; - -using System.Collections.Generic; -using System.Threading; - -using Xunit; - -using static RateLimiter.Config.RateLimiterConfiguration; - -namespace RateLimiter.Tests; - -public class RateLimiterTest -{ - /// - /// Note: This test class is not true unit testing and would not exist in this project - /// Chose to use concrete implementations in some places to facilitate functional testing - /// - [Fact] - public void IsRequestAllowed() - { - var mocker = new AutoMocker(); - var fixture = new Fixture(); - - // arrange - var appOptions = Options.Create(new RateLimiterConfiguration() - { - DefaultAlgorithm = RateLimitingAlgorithm.FixedWindow, - DefaultMaxRequests = 5, - DefaultTimespanMilliseconds = 3000, - Rules = GenerateRateLimitRules() - }); - - mocker.GetMock>() - .Setup(s => s.Value) - .Returns(appOptions.Value); - - mocker.Use(new DateTimeProvider()); - mocker.Use(new DiscriminatorProvider(null, null)); - - //// mock the rules as would be defined within appSettings - //var rateLimitRules = GenerateRateLimitRules(); - //mocker.GetMock() - // .Setup(s => s.GetRules(new RateLimiterConfiguration())) - // .Returns(rateLimitRules); - mocker.Use(new RateLimiterRulesFactory()); - - // mock the rule attribute as would be applied to our resource's endpoint - var rateLimitedResources = new List() - { - fixture.Build() - .With(x => x.RuleName, "RequestPerTimespan-Default") - .Create() - }; - - var context = new HttpContextMock() - .SetupUrl("http://localhost:8000/path") - .SetupRequestHeaders(new Dictionary() - { - { "Host", "192.168.0.1"} - }) - .SetupRequestMethod("GET"); - - var algoProvider = mocker.CreateInstance(); - mocker.Use(algoProvider); - - var limiter = mocker.CreateInstance(); +//using AutoFixture; + +//using FluentAssertions; + +//using HttpContextMoq; +//using HttpContextMoq.Extensions; + +//using Microsoft.Extensions.Options; +//using Microsoft.Extensions.Primitives; + +//using Moq.AutoMock; + +//using RateLimiter.Abstractions; +//using RateLimiter.Common; +//using RateLimiter.Config; +//using RateLimiter.Discriminators; +//using RateLimiter.Enums; +//using RateLimiter.Rules.Algorithms; + +//using System.Collections.Generic; +//using System.Threading; + +//using Xunit; + +//using static RateLimiter.Config.RateLimiterConfiguration; + +//namespace RateLimiter.Tests; + +//public class RateLimiterTest +//{ +// /// +// /// Note: This test class is not true unit testing and would not exist in this project +// /// Chose to use concrete implementations in some places to facilitate functional testing +// /// +// [Fact] +// public void IsRequestAllowed() +// { +// var mocker = new AutoMocker(); +// var fixture = new Fixture(); + +// // arrange +// var appOptions = Options.Create(new RateLimiterConfiguration() +// { +// DefaultAlgorithmType = AlgorithmType.FixedWindow, +// DefaultMaxRequests = 5, +// DefaultTimespanMilliseconds = 3000, +// Rules = GenerateRateLimitRules() +// }); + +// mocker.GetMock>() +// .Setup(s => s.Value) +// .Returns(appOptions.Value); + +// mocker.Use(new DateTimeProvider()); +// mocker.Use(new DiscriminatorProvider(null, null)); + +// //// mock the rules as would be defined within appSettings +// //var rateLimitRules = GenerateRateLimitRules(); +// //mocker.GetMock() +// // .Setup(s => s.GetRules(new RateLimiterConfiguration())) +// // .Returns(rateLimitRules); +// mocker.Use(new RateLimiterRulesFactory()); + +// // mock the rule attribute as would be applied to our resource's endpoint +// var rateLimitedResources = new List() +// { +// fixture.Build() +// .With(x => x.RuleName, "RequestPerTimespan-Default") +// .Create() +// }; + +// var context = new HttpContextMock() +// .SetupUrl("http://localhost:8000/path") +// .SetupRequestHeaders(new Dictionary() +// { +// { "Host", "192.168.0.1"} +// }) +// .SetupRequestMethod("GET"); + +// var algoProvider = mocker.CreateInstance(); +// mocker.Use(algoProvider); + +// var limiter = mocker.CreateInstance(); - // act - const int numberOfRequestsToTry = 4; +// // act +// const int numberOfRequestsToTry = 4; - for (var i = 0; i < numberOfRequestsToTry; i++) - { - var result = limiter.IsRequestAllowed(context, rateLimitedResources); +// for (var i = 0; i < numberOfRequestsToTry; i++) +// { +// var result = limiter.IsRequestAllowed(context, rateLimitedResources); - // assert - if (i <= 2) - { - result.RequestIsAllowed.Should().BeTrue(); - result.ErrorMessage.Should().BeNullOrEmpty(); - } - else - { - result.RequestIsAllowed.Should().BeFalse(); - result.ErrorMessage.Should().NotBeNullOrEmpty(); - - // wait 3 seconds - Thread.Sleep(3000); - - result = limiter.IsRequestAllowed(context, rateLimitedResources); - - result.RequestIsAllowed.Should().BeTrue(); - result.ErrorMessage.Should().BeNullOrEmpty(); - } - } - } - - private static List GenerateRateLimitRules() - { - var fixture = new Fixture(); - var values = new List - { - fixture.Build() - .With(x => x.Name, "RequestPerTimespan-Default") - .With(x => x.Type, LimiterType.RequestsPerTimespan) - .With(x => x.Discriminator, LimiterDiscriminator.IpAddress) - .With(x => x.DiscriminatorMatch, string.Empty) - .With(x => x.DiscriminatorKey, string.Empty) - .With(x => x.MaxRequests, 3) - .With(x => x.TimespanMilliseconds, 3000) - .With(x => x.Algorithm, RateLimitingAlgorithm.Default) - .Create(), - fixture.Build() - .With(x => x.Name, "ApiKey-Default") - .With(x => x.Type, LimiterType.RequestsPerTimespan) - .With(x => x.Discriminator, LimiterDiscriminator.QueryString) - .With(x => x.DiscriminatorMatch, "x-crexi-token") - .With(x => x.DiscriminatorKey, "US") - .With(x => x.Algorithm, RateLimitingAlgorithm.Default) - .With(x => x.TimespanMilliseconds, 4000) - .Create() - }; - - return values; - } -} \ No newline at end of file +// // assert +// if (i <= 2) +// { +// result.RequestIsAllowed.Should().BeTrue(); +// result.ErrorMessage.Should().BeNullOrEmpty(); +// } +// else +// { +// result.RequestIsAllowed.Should().BeFalse(); +// result.ErrorMessage.Should().NotBeNullOrEmpty(); + +// // wait 3 seconds +// Thread.Sleep(3000); + +// result = limiter.IsRequestAllowed(context, rateLimitedResources); + +// result.RequestIsAllowed.Should().BeTrue(); +// result.ErrorMessage.Should().BeNullOrEmpty(); +// } +// } +// } + +// private static List GenerateRateLimitRules() +// { +// var fixture = new Fixture(); +// var values = new List +// { +// fixture.Build() +// .With(x => x.Name, "RequestPerTimespan-Default") +// .With(x => x.Type, LimiterType.RequestsPerTimespan) +// .With(x => x.Discriminator, DiscriminatorType.IpAddress) +// .With(x => x.DiscriminatorMatch, string.Empty) +// .With(x => x.DiscriminatorKey, string.Empty) +// .With(x => x.MaxRequests, 3) +// .With(x => x.TimespanMilliseconds, 3000) +// .With(x => x.Algorithm, AlgorithmType.Default) +// .Create(), +// fixture.Build() +// .With(x => x.Name, "ApiKey-Default") +// .With(x => x.Type, LimiterType.RequestsPerTimespan) +// .With(x => x.Discriminator, DiscriminatorType.QueryString) +// .With(x => x.DiscriminatorMatch, "x-crexi-token") +// .With(x => x.DiscriminatorKey, "US") +// .With(x => x.Algorithm, AlgorithmType.Default) +// .With(x => x.TimespanMilliseconds, 4000) +// .Create() +// }; + +// return values; +// } +//} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs b/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs index 2a41e7d2..20c864e3 100644 --- a/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs +++ b/RateLimiter.Tests/Rules/Algorithms/AlgorithmProviderTests.cs @@ -1,34 +1,34 @@ -using FluentAssertions; +//using FluentAssertions; -using RateLimiter.Enums; -using RateLimiter.Rules.Algorithms; +//using RateLimiter.Enums; +//using RateLimiter.Rules.Algorithms; -using System; +//using System; -using Xunit; +//using Xunit; -namespace RateLimiter.Tests.Rules.Algorithms -{ - public class AlgorithmProviderTests : UnitTestBase - { - [Theory] - [InlineData(RateLimitingAlgorithm.Default, RateLimitingAlgorithm.FixedWindow)] - [InlineData(RateLimitingAlgorithm.FixedWindow, RateLimitingAlgorithm.FixedWindow)] - [InlineData(RateLimitingAlgorithm.LeakyBucket, RateLimitingAlgorithm.LeakyBucket)] - [InlineData(RateLimitingAlgorithm.SlidingWindow, RateLimitingAlgorithm.SlidingWindow)] - [InlineData(RateLimitingAlgorithm.TokenBucket, RateLimitingAlgorithm.TokenBucket)] - public void GetAlgorithm_WithValidData_ProvidesCorrectAlgorithm( - RateLimitingAlgorithm algo, - RateLimitingAlgorithm expectedAlgorithm) - { - // arrange - var sut = Mocker.CreateInstance(); +//namespace RateLimiter.Tests.Rules.Algorithms +//{ +// public class AlgorithmProviderTests : UnitTestBase +// { +// [Theory] +// [InlineData(AlgorithmType.Default, AlgorithmType.FixedWindow)] +// [InlineData(AlgorithmType.FixedWindow, AlgorithmType.FixedWindow)] +// [InlineData(AlgorithmType.LeakyBucket, AlgorithmType.LeakyBucket)] +// [InlineData(AlgorithmType.SlidingWindow, AlgorithmType.SlidingWindow)] +// [InlineData(AlgorithmType.TokenBucket, AlgorithmType.TokenBucket)] +// public void GetAlgorithm_WithValidData_ProvidesCorrectAlgorithm( +// AlgorithmType algo, +// AlgorithmType expectedAlgorithmType) +// { +// // arrange +// var sut = Mocker.CreateInstance(); - // act - var result = sut.GetAlgorithm(algo, 5, TimeSpan.FromMilliseconds(3000)); +// // act +// var result = sut.GetAlgorithm(algo, 5, TimeSpan.FromMilliseconds(3000)); - // assert - result.Algorithm.Should().Be(expectedAlgorithm); - } - } -} +// // assert +// result.AlgorithmType.Should().Be(expectedAlgorithmType); +// } +// } +//} diff --git a/RateLimiter/Abstractions/IDefineARateLimitRule.cs b/RateLimiter/Abstractions/IDefineARateLimitRule.cs deleted file mode 100644 index 839ac3f3..00000000 --- a/RateLimiter/Abstractions/IDefineARateLimitRule.cs +++ /dev/null @@ -1,20 +0,0 @@ -using RateLimiter.Enums; - -namespace RateLimiter.Abstractions; - -public interface IDefineARateLimitRule -{ - LimiterType Type { get; } - - string Name { get; set; } - - LimiterDiscriminator Discriminator { get; set; } - - string? CustomDiscriminatorName { get; set; } - - string? DiscriminatorKey { get; set; } - - string? DiscriminatorMatch { get; set; } - - RateLimitingAlgorithm Algorithm { get; set; } -} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IProvideADiscriminator.cs b/RateLimiter/Abstractions/IProvideADiscriminator.cs deleted file mode 100644 index 146d268e..00000000 --- a/RateLimiter/Abstractions/IProvideADiscriminator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace RateLimiter.Abstractions -{ - public interface IProvideADiscriminator - { - (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule); - } -} diff --git a/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs b/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs deleted file mode 100644 index e5eae9c1..00000000 --- a/RateLimiter/Abstractions/IProvideDiscriminatorValues.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Http; - -using System.Collections.Generic; - -namespace RateLimiter.Abstractions; - -public interface IProvideDiscriminatorValues -{ - Dictionary GetDiscriminatorValues( - HttpContext context, - IEnumerable rules); -} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs b/RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs deleted file mode 100644 index f87be154..00000000 --- a/RateLimiter/Abstractions/IProvideRateLimitAlgorithms.cs +++ /dev/null @@ -1,14 +0,0 @@ -using RateLimiter.Enums; - -using System; - -namespace RateLimiter.Abstractions -{ - public interface IProvideRateLimitAlgorithms - { - IAmARateLimitAlgorithm GetAlgorithm( - RateLimitingAlgorithm algo, - int? maxRequests, - TimeSpan? timespanMilliseconds); - } -} diff --git a/RateLimiter/Abstractions/IProvideRateLimitRules.cs b/RateLimiter/Abstractions/IProvideRateLimitRules.cs deleted file mode 100644 index fc579c11..00000000 --- a/RateLimiter/Abstractions/IProvideRateLimitRules.cs +++ /dev/null @@ -1,10 +0,0 @@ -using RateLimiter.Config; - -using System.Collections.Generic; - -namespace RateLimiter.Abstractions; - -public interface IProvideRateLimitRules -{ - IEnumerable GetRules(RateLimiterConfiguration config); -} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IAmARateLimitAlgorithm.cs b/RateLimiter/Abstractions/IRateLimitAlgorithm.cs similarity index 61% rename from RateLimiter/Abstractions/IAmARateLimitAlgorithm.cs rename to RateLimiter/Abstractions/IRateLimitAlgorithm.cs index 6cf294aa..50843734 100644 --- a/RateLimiter/Abstractions/IAmARateLimitAlgorithm.cs +++ b/RateLimiter/Abstractions/IRateLimitAlgorithm.cs @@ -2,11 +2,11 @@ namespace RateLimiter.Abstractions; -public interface IAmARateLimitAlgorithm +public interface IRateLimitAlgorithm { string Name { get; init; } bool IsAllowed(string discriminator); - RateLimitingAlgorithm Algorithm { get; init; } + AlgorithmType AlgorithmType { get; init; } } \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitAlgorithmConfiguration.cs b/RateLimiter/Abstractions/IRateLimitAlgorithmConfiguration.cs new file mode 100644 index 00000000..39474634 --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimitAlgorithmConfiguration.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Abstractions +{ + public interface IRateLimitAlgorithmConfiguration + { + } +} diff --git a/RateLimiter/Abstractions/IRateLimitAlgorithmProvider.cs b/RateLimiter/Abstractions/IRateLimitAlgorithmProvider.cs new file mode 100644 index 00000000..99b4f721 --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimitAlgorithmProvider.cs @@ -0,0 +1,12 @@ +using RateLimiter.Config; + +using System.Collections.Concurrent; + +namespace RateLimiter.Abstractions +{ + public interface IRateLimitAlgorithmProvider + { + ConcurrentDictionary + GenerateAlgorithmsFromRules(RateLimiterConfiguration configuration); + } +} diff --git a/RateLimiter/Abstractions/IRateLimitDiscriminator.cs b/RateLimiter/Abstractions/IRateLimitDiscriminator.cs new file mode 100644 index 00000000..fce6ff16 --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimitDiscriminator.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +using RateLimiter.Config; +using RateLimiter.Discriminators; + +namespace RateLimiter.Abstractions +{ + public interface IRateLimitDiscriminator + { + RateLimiterConfiguration.DiscriminatorConfiguration Configuration { get; set; } + + DiscriminatorEvaluationResult Evaluate(HttpContext context); + } +} diff --git a/RateLimiter/Abstractions/IRateLimitDiscriminatorProvider.cs b/RateLimiter/Abstractions/IRateLimitDiscriminatorProvider.cs new file mode 100644 index 00000000..bbba7d5b --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimitDiscriminatorProvider.cs @@ -0,0 +1,13 @@ +using RateLimiter.Config; + +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Abstractions +{ + public interface IRateLimitDiscriminatorProvider + { + ConcurrentDictionary GenerateDiscriminators( + List configDiscriminators); + } +} diff --git a/RateLimiter/Abstractions/IRateLimitRequests.cs b/RateLimiter/Abstractions/IRateLimiter.cs similarity index 88% rename from RateLimiter/Abstractions/IRateLimitRequests.cs rename to RateLimiter/Abstractions/IRateLimiter.cs index e152d8b4..17e27351 100644 --- a/RateLimiter/Abstractions/IRateLimitRequests.cs +++ b/RateLimiter/Abstractions/IRateLimiter.cs @@ -6,7 +6,7 @@ namespace RateLimiter.Abstractions; -public interface IRateLimitRequests +public interface IRateLimiter { (bool RequestIsAllowed, string ErrorMessage) IsRequestAllowed(HttpContext context, IEnumerable rateLimitedResources); } \ No newline at end of file diff --git a/RateLimiter/Config/RateLimiterConfiguration.cs b/RateLimiter/Config/RateLimiterConfiguration.cs index 67f42b67..2e78fe0c 100644 --- a/RateLimiter/Config/RateLimiterConfiguration.cs +++ b/RateLimiter/Config/RateLimiterConfiguration.cs @@ -6,32 +6,57 @@ namespace RateLimiter.Config; public class RateLimiterConfiguration { - public RateLimitingAlgorithm DefaultAlgorithm { get; set; } + public List Algorithms { get; set; } = new(); - public int DefaultMaxRequests { get; set; } + public List Discriminators { get; set; } = new(); - public int DefaultTimespanMilliseconds { get; set; } + public List Rules { get; set; } = new(); - public List Rules { get; set; } + public class AlgorithmConfiguration + { + public string Name { get; set; } + + public AlgorithmType Type { get; set; } + + public AlgorithmConfigurationParameters Parameters { get; set; } + + public class AlgorithmConfigurationParameters + { + public int? MinIntervalMS { get; set; } + + public int? MaxRequests { get; set; } + + public int? WindowDurationMS { get; set; } + + public int? Capacity { get; set; } + + public int? IntervalMS { get; set; } + + public int? MaxTokens { get; set; } - public class RateLimiterRuleItemConfiguration + public int? RefillRatePerSecond { get; set; } + } + } + + public class RuleConfiguration { public string Name { get; set; } - public LimiterType Type { get; set; } + public List Discriminators { get; set; } = new(); + } + + public class DiscriminatorConfiguration + { + public string Name { get; set; } - public LimiterDiscriminator Discriminator { get; set; } + public DiscriminatorType Type { get; set; } public string? CustomDiscriminatorType { get; set; } - public string? DiscriminatorMatch { get; set; } - public string? DiscriminatorKey { get; set; } - public int? MaxRequests { get; set; } - - public int? TimespanMilliseconds { get; set; } + public string? DiscriminatorMatch { get; set; } - public RateLimitingAlgorithm? Algorithm { get; set; } + public List AlgorithmNames { get; set; } = new(); } } \ No newline at end of file diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs index 29a7b619..42aa3f15 100644 --- a/RateLimiter/DependencyInjection/RateLimiterRegister.cs +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -16,10 +16,9 @@ public static class RateLimiterRegister public static IServiceCollection AddRateLimiting(this IServiceCollection services) { services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } @@ -30,9 +29,9 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service /// /// public static IServiceCollection WithCustomDiscriminator(this IServiceCollection services) - where T : class, IProvideADiscriminator + where T : class, IRateLimitDiscriminator { - services.AddKeyedSingleton(typeof(T).Name); + services.AddKeyedSingleton(typeof(T).Name); return services; } diff --git a/RateLimiter/Discriminators/DiscriminatorEvaluationResult.cs b/RateLimiter/Discriminators/DiscriminatorEvaluationResult.cs new file mode 100644 index 00000000..76a5fbea --- /dev/null +++ b/RateLimiter/Discriminators/DiscriminatorEvaluationResult.cs @@ -0,0 +1,13 @@ +namespace RateLimiter.Discriminators +{ + public class DiscriminatorEvaluationResult(string discriminatorName) + { + public string DiscriminatorName { get; init; } = discriminatorName; + + public bool IsMatch { get; set; } + + public string MatchValue { get; set; } + + public string? AlgorithmName { get; set; } + } +} diff --git a/RateLimiter/Discriminators/DiscriminatorProvider.cs b/RateLimiter/Discriminators/DiscriminatorProvider.cs index 3ff7065a..2da5aa6a 100644 --- a/RateLimiter/Discriminators/DiscriminatorProvider.cs +++ b/RateLimiter/Discriminators/DiscriminatorProvider.cs @@ -1,8 +1,8 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using RateLimiter.Abstractions; +using RateLimiter.Config; using RateLimiter.Enums; using System; @@ -11,13 +11,11 @@ namespace RateLimiter.Discriminators { - public class DiscriminatorProvider : IProvideDiscriminatorValues + public class DiscriminatorProvider : IRateLimitDiscriminatorProvider { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; - private readonly ConcurrentDictionary _discriminators = new(); - public DiscriminatorProvider( ILogger logger, IServiceProvider serviceProvider) @@ -26,108 +24,40 @@ public DiscriminatorProvider( _serviceProvider = serviceProvider; } - public Dictionary GetDiscriminatorValues( - HttpContext context, - IEnumerable rules) + public ConcurrentDictionary GenerateDiscriminators( + List configDiscriminators) { - // TODO: These values should likely be cached in the caller - - var results = new Dictionary(); + var values = new ConcurrentDictionary(); - // for each rule in here, we need to generate the discriminator value - foreach (var rule in rules) + foreach (var disc in configDiscriminators) { - switch (rule.Discriminator) + switch (disc.Type) { - case LimiterDiscriminator.QueryString: - results.Add(rule.Name, GetQuerystringValue(context, rule)); + case DiscriminatorType.Custom: + var customDiscriminator = _serviceProvider + .GetRequiredKeyedService(disc.CustomDiscriminatorType); + customDiscriminator.Configuration = disc; + values.TryAdd(disc.Name, customDiscriminator); + break; + case DiscriminatorType.GeoLocation: + values.TryAdd(disc.Name, new GeoBasedDiscriminator(disc)); break; - case LimiterDiscriminator.RequestHeader: - results.Add(rule.Name, GetRequestHeaderValue(context, rule)); + case DiscriminatorType.IpAddress: + values.TryAdd(disc.Name, new IpAddressDiscriminator(disc)); break; - case LimiterDiscriminator.IpAddress: - results.Add(rule.Name, GetIpAddressValue(context, rule)); + case DiscriminatorType.QueryString: + values.TryAdd(disc.Name, new QueryStringDiscriminator(disc)); break; - case LimiterDiscriminator.Custom: - results.Add(rule.Name, GetCustomValue(_serviceProvider, context, rule)); + case DiscriminatorType.RequestHeader: + values.TryAdd(disc.Name, new RequestHeaderDiscriminator(disc)); break; - case LimiterDiscriminator.GeoLocation: - case LimiterDiscriminator.IpSubNet: + case DiscriminatorType.IpSubNet: default: throw new ArgumentOutOfRangeException(); } } - return results; - } - - // TODO: Refactor to generic - private (bool IsMatch, string MatchValue) GetCustomValue(IServiceProvider serviceProvider, HttpContext context, IDefineARateLimitRule rule) - { - IProvideADiscriminator discriminator; - - if (!_discriminators.TryGetValue(rule.Name, out var value)) - { - discriminator = serviceProvider.GetRequiredKeyedService(rule.CustomDiscriminatorName); - _discriminators.TryAdd(rule.Name, discriminator); - } - else - { - discriminator = value; - } - - return discriminator.GetDiscriminator(context, rule); - } - - private (bool IsMatch, string MatchValue) GetIpAddressValue(HttpContext context, IDefineARateLimitRule rule) - { - IProvideADiscriminator discriminator; - - if (!_discriminators.TryGetValue(rule.Name, out var value)) - { - discriminator = new IpAddressDiscriminator(); - _discriminators.TryAdd(rule.Name, discriminator); - } - else - { - discriminator = value; - } - - return discriminator.GetDiscriminator(context, rule); - } - - private (bool IsMatch, string MatchValue) GetRequestHeaderValue(HttpContext context, IDefineARateLimitRule rule) - { - IProvideADiscriminator discriminator; - - if (!_discriminators.TryGetValue(rule.Name, out var value)) - { - discriminator = new RequestHeaderDiscriminator(); - _discriminators.TryAdd(rule.Name, discriminator); - } - else - { - discriminator = value; - } - - return discriminator.GetDiscriminator(context, rule); - } - - private (bool IsMatch, string MatchValue) GetQuerystringValue(HttpContext context, IDefineARateLimitRule rule) - { - IProvideADiscriminator discriminator; - - if (!_discriminators.TryGetValue(rule.Name, out var value)) - { - discriminator = new QueryStringDiscriminator(); - _discriminators.TryAdd(rule.Name, discriminator); - } - else - { - discriminator = value; - } - - return discriminator.GetDiscriminator(context, rule); + return values; } } } diff --git a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs index 2ffff4b7..6910b029 100644 --- a/RateLimiter/Discriminators/GeoBasedDiscriminator.cs +++ b/RateLimiter/Discriminators/GeoBasedDiscriminator.cs @@ -2,18 +2,26 @@ using RateLimiter.Abstractions; +using static RateLimiter.Config.RateLimiterConfiguration; + namespace RateLimiter.Discriminators { - public class GeoBasedDiscriminator : IProvideADiscriminator + public class GeoBasedDiscriminator(DiscriminatorConfiguration configuration) : IRateLimitDiscriminator { - public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public DiscriminatorConfiguration Configuration { get; set; } + + public DiscriminatorEvaluationResult Evaluate(HttpContext context) { // get the ip address via cache/external source // perform a geo lookup on it // return the geolocation - return (false, "US"); + return new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = false, + MatchValue = "US" + }; } } } diff --git a/RateLimiter/Discriminators/IpAddressDiscriminator.cs b/RateLimiter/Discriminators/IpAddressDiscriminator.cs index 15b0a693..31a8fde1 100644 --- a/RateLimiter/Discriminators/IpAddressDiscriminator.cs +++ b/RateLimiter/Discriminators/IpAddressDiscriminator.cs @@ -2,22 +2,38 @@ using RateLimiter.Abstractions; +using static RateLimiter.Config.RateLimiterConfiguration; + namespace RateLimiter.Discriminators { - public class IpAddressDiscriminator : IProvideADiscriminator + public class IpAddressDiscriminator(DiscriminatorConfiguration configuration) : IRateLimitDiscriminator { - public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public DiscriminatorConfiguration Configuration { get; set; } + + public DiscriminatorEvaluationResult Evaluate(HttpContext context) { // TODO: This is likely incorrect. Cannot test b/c shows "localhost" var ipAddress = context.Request.Headers.Host.ToString(); - if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || - rateLimitRule.DiscriminatorMatch == "*") - return (true, ipAddress); + if (string.IsNullOrEmpty(configuration.DiscriminatorMatch) || + configuration.DiscriminatorMatch == "*") + return new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = true, + MatchValue = ipAddress + }; - return rateLimitRule.DiscriminatorMatch == ipAddress ? - (true, ipAddress) : - (false, ipAddress); + return configuration.DiscriminatorMatch == ipAddress ? + new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = true, + MatchValue = ipAddress + } : + new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = false, + MatchValue = ipAddress + }; } } } diff --git a/RateLimiter/Discriminators/QueryStringDiscriminator.cs b/RateLimiter/Discriminators/QueryStringDiscriminator.cs index 346cef91..c8c48f63 100644 --- a/RateLimiter/Discriminators/QueryStringDiscriminator.cs +++ b/RateLimiter/Discriminators/QueryStringDiscriminator.cs @@ -2,30 +2,46 @@ using RateLimiter.Abstractions; +using static RateLimiter.Config.RateLimiterConfiguration; + namespace RateLimiter.Discriminators { - public class QueryStringDiscriminator : IProvideADiscriminator + public class QueryStringDiscriminator(DiscriminatorConfiguration configuration) : IRateLimitDiscriminator { - public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public DiscriminatorConfiguration Configuration { get; set; } + + public DiscriminatorEvaluationResult Evaluate(HttpContext context) { - if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorKey)) + if (string.IsNullOrEmpty(configuration.DiscriminatorKey)) { // likely should log and throw - return (false, string.Empty); + return new DiscriminatorEvaluationResult(configuration.Name); } - if (!context.Request.Query.TryGetValue(rateLimitRule.DiscriminatorKey, out var value)) + if (!context.Request.Query.TryGetValue(configuration.DiscriminatorKey, out var value)) { - return (false, string.Empty); + return new DiscriminatorEvaluationResult(configuration.Name); } - if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || - rateLimitRule.DiscriminatorMatch == "*") - return (true, value.ToString()); + if (string.IsNullOrEmpty(configuration.DiscriminatorMatch) || + configuration.DiscriminatorMatch == "*") + return new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = true, + MatchValue = value.ToString() + }; - return rateLimitRule.DiscriminatorMatch == value.ToString() ? - (true, value.ToString()) : - (false, value.ToString()); + return configuration.DiscriminatorMatch == value.ToString() ? + new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = true, + MatchValue = value.ToString() + } : + new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = false, + MatchValue = value.ToString() + }; } } } diff --git a/RateLimiter/Discriminators/RequestHeaderDiscriminator.cs b/RateLimiter/Discriminators/RequestHeaderDiscriminator.cs index 2ff3f87f..a40e7b83 100644 --- a/RateLimiter/Discriminators/RequestHeaderDiscriminator.cs +++ b/RateLimiter/Discriminators/RequestHeaderDiscriminator.cs @@ -2,30 +2,46 @@ using RateLimiter.Abstractions; +using static RateLimiter.Config.RateLimiterConfiguration; + namespace RateLimiter.Discriminators { - public class RequestHeaderDiscriminator : IProvideADiscriminator + public class RequestHeaderDiscriminator(DiscriminatorConfiguration configuration) : IRateLimitDiscriminator { - public (bool IsMatch, string MatchValue) GetDiscriminator(HttpContext context, IDefineARateLimitRule rateLimitRule) + public DiscriminatorConfiguration Configuration { get; set; } + + public DiscriminatorEvaluationResult Evaluate(HttpContext context) { - if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorKey)) + if (string.IsNullOrEmpty(configuration.DiscriminatorKey)) { // likely should log and throw - return (false, string.Empty); + return new DiscriminatorEvaluationResult(configuration.Name); } - if (!context.Request.Headers.TryGetValue(rateLimitRule.DiscriminatorKey, out var value)) + if (!context.Request.Headers.TryGetValue(configuration.DiscriminatorKey, out var value)) { - return (false, string.Empty); + return new DiscriminatorEvaluationResult(configuration.Name); } - if (string.IsNullOrEmpty(rateLimitRule.DiscriminatorMatch) || - rateLimitRule.DiscriminatorMatch == "*") - return (true, value.ToString()); + if (string.IsNullOrEmpty(configuration.DiscriminatorMatch) || + configuration.DiscriminatorMatch == "*") + return new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = true, + MatchValue = value.ToString() + }; - return rateLimitRule.DiscriminatorMatch == value.ToString() ? - (true, value.ToString()) : - (false, value.ToString()); + return configuration.DiscriminatorMatch == value.ToString() ? + new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = true, + MatchValue = value.ToString() + } : + new DiscriminatorEvaluationResult(configuration.Name) + { + IsMatch = false, + MatchValue = value.ToString() + }; } } } diff --git a/RateLimiter/Enums/RateLimitingAlgorithm.cs b/RateLimiter/Enums/AlgorithmType.cs similarity index 80% rename from RateLimiter/Enums/RateLimitingAlgorithm.cs rename to RateLimiter/Enums/AlgorithmType.cs index 1c5fb314..90f55a07 100644 --- a/RateLimiter/Enums/RateLimitingAlgorithm.cs +++ b/RateLimiter/Enums/AlgorithmType.cs @@ -1,6 +1,6 @@ namespace RateLimiter.Enums; -public enum RateLimitingAlgorithm +public enum AlgorithmType { Default, FixedWindow, diff --git a/RateLimiter/Enums/LimiterDiscriminator.cs b/RateLimiter/Enums/DiscriminatorType.cs similarity index 79% rename from RateLimiter/Enums/LimiterDiscriminator.cs rename to RateLimiter/Enums/DiscriminatorType.cs index dea1b68c..f6b86437 100644 --- a/RateLimiter/Enums/LimiterDiscriminator.cs +++ b/RateLimiter/Enums/DiscriminatorType.cs @@ -1,6 +1,6 @@ namespace RateLimiter.Enums; -public enum LimiterDiscriminator +public enum DiscriminatorType { Custom, GeoLocation, diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs index ec858a9d..6ddf47ae 100644 --- a/RateLimiter/Middleware/RateLimiterMiddleware.cs +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -14,12 +14,12 @@ public class RateLimiterMiddleware { private readonly ILogger _logger; private readonly RequestDelegate _next; - private readonly IRateLimitRequests _rateLimiter; + private readonly IRateLimiter _rateLimiter; public RateLimiterMiddleware( ILogger logger, RequestDelegate next, - IRateLimitRequests rateLimiter) + IRateLimiter rateLimiter) { _logger = logger; _next = next; diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 8b398651..d43a5f05 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -4,95 +4,69 @@ using RateLimiter.Abstractions; using RateLimiter.Config; -using RateLimiter.Enums; -using RateLimiter.Rules; +using RateLimiter.Discriminators; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Data; using System.Linq; +using static RateLimiter.Config.RateLimiterConfiguration; + namespace RateLimiter; -public class RateLimiter : IRateLimitRequests +public class RateLimiter : IRateLimiter { private readonly ILogger _logger; - private readonly IProvideRateLimitAlgorithms _algorithmProvider; + private readonly IOptions _options; + private readonly RateLimiterConfiguration _config; + private readonly IRateLimitAlgorithmProvider _algorithmProvider; /// /// List of rules as defined in appSettings.RateLimiter section (or via Fluent registration) /// - private readonly IEnumerable _rules; + private readonly IEnumerable _rules; + + private readonly IRateLimitDiscriminatorProvider _discriminatorsProvider; - private readonly IProvideDiscriminatorValues _discriminatorsProvider; - private readonly ConcurrentDictionary _ruleNameAlgorithm; + private ConcurrentDictionary _algorithms; + private ConcurrentDictionary _discriminators; public RateLimiter( ILogger logger, IOptions options, - IProvideRateLimitRules rulesFactory, - IProvideDiscriminatorValues discriminatorsProvider, - IProvideRateLimitAlgorithms algorithmProvider) + IRateLimitDiscriminatorProvider discriminatorsProvider, + IRateLimitAlgorithmProvider algorithmProvider + ) { _logger = logger; _algorithmProvider = algorithmProvider; - // TODO: IOptions should be replaced with IOptionsMonitor for hot-reloading - _rules = rulesFactory.GetRules(options.Value); - - // We need to instantiate an instance of an algorithm for each configuration we find - // Why? Even though 2 rules might specify the same algo, the config-based specifics could be different - _ruleNameAlgorithm = GenerateAlgorithmsFromRules(_rules); - + _options = options; + _config = options.Value; + _rules = options.Value.Rules; _discriminatorsProvider = discriminatorsProvider; + ValidateConfiguration(_config); + ProcessConfiguration(_config); + } - ValidateConfiguration(); + private void ValidateConfiguration(RateLimiterConfiguration config) + { + return; } - /// - /// Incomplete: Validate configuration and registrations upon instantiation in order to prevent downstream runtime errors & exceptions - /// - private void ValidateConfiguration() + private void ProcessConfiguration(RateLimiterConfiguration configuration) { - foreach (var rule in _rules) - { - switch (rule.Discriminator) - { - case LimiterDiscriminator.Custom: - if (string.IsNullOrEmpty(rule.CustomDiscriminatorName)) - throw new MissingFieldException("Rule uses a custom discriminator, but none was provided. {@RuleName}", rule.Name); - - break; - case LimiterDiscriminator.GeoLocation: - case LimiterDiscriminator.IpAddress: - case LimiterDiscriminator.IpSubNet: - break; - case LimiterDiscriminator.QueryString: - if (string.IsNullOrEmpty(rule.DiscriminatorKey)) - throw new MissingFieldException("Rule uses a querystring discriminator, but DiscriminatorKey was not provided. {@RuleName}", rule.Name); - break; - case LimiterDiscriminator.RequestHeader: - if (string.IsNullOrEmpty(rule.DiscriminatorKey)) - throw new MissingFieldException("Rule uses a request header discriminator, but DiscriminatorKey was not provided. {@RuleName}", rule.Name); - break; - default: - throw new ArgumentOutOfRangeException(); - } - - switch (rule.Type) - { - case LimiterType.RequestsPerTimespan: - break; - case LimiterType.TimespanElapsed: - // ensure this is correct; cannot be anything else for this rule type - // do not throw an exception, simply correct it - if (rule.Algorithm != RateLimitingAlgorithm.TimespanElapsed) - rule.Algorithm = RateLimitingAlgorithm.TimespanElapsed; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } + /* Preload Algorithms */ + // We need to instantiate an instance of an algorithm for each configuration we find + // Why? Even though 2 rules might specify the same algo, the config-based specifics could be different + // From the rules we have configured in appSettings for our rate limiter, + // we need to instantiate an algorithm configured to satisfy the rule's configuration + _algorithms = _algorithmProvider.GenerateAlgorithmsFromRules(configuration); + + /* Preload Discriminators */ + _discriminators = _discriminatorsProvider.GenerateDiscriminators(_config.Discriminators); } public (bool RequestIsAllowed, string ErrorMessage) IsRequestAllowed( @@ -100,7 +74,7 @@ private void ValidateConfiguration() IEnumerable rateLimitedResources) { // get the matching rules for this request - var matchingRules = _rules.Where(r => rateLimitedResources + var matchingRules = _config.Rules.Where(r => rateLimitedResources .Select(x => x.RuleName) .ToList().Contains(r.Name)) .ToList(); @@ -111,27 +85,33 @@ private void ValidateConfiguration() return (true, string.Empty); } - // need to get the discriminator for each incoming rate limit configuration - // key: ruleName value: (IsMatch, MatchValue) - var discriminatorValues = _discriminatorsProvider - .GetDiscriminatorValues(context, matchingRules) - .Where(x => x.Value.IsMatch) + // get the discriminator(s) for each rule to be processed for this request + var discriminatorNames = matchingRules.SelectMany(x => x.Discriminators); + + var discriminatorsToProcess = _discriminators + .Where(x => discriminatorNames.Contains(x.Key)) + .Select(y => y.Value) .ToList(); + if (discriminatorsToProcess.Count == 0) + { + // this is likely a logical/configuration error + return (true, string.Empty); + } + // now we need to filter down the matchingRules only to those whose discriminators matched their condition(s) - matchingRules = matchingRules - .Where(x => discriminatorValues.Select(y => y.Key) - .Contains(x.Name)) - .ToList(); + var results = new List(); + discriminatorsToProcess.ForEach(x => + { + results.Add(x.Evaluate(context)); + }); - // TODO: Make this a single call (no iterations) var passed = true; var lastRule = string.Empty; - foreach (var rule in matchingRules) + foreach (var x in results) { - lastRule = rule.Name; - passed = _ruleNameAlgorithm[rule.Name] - .IsAllowed(discriminatorValues.First(x => x.Key == rule.Name).Value.MatchValue); + lastRule = $"{x.DiscriminatorName}:{x.AlgorithmName}"; + passed = _algorithms[x.AlgorithmName].IsAllowed(x.MatchValue); if (!passed) break; } @@ -140,79 +120,4 @@ private void ValidateConfiguration() return passed ? (passed, string.Empty) : (passed, $"some message about banging on our door too much due to: {lastRule}"); } - - /// - /// From the rules we have configured in appSettings for our rate limiter, - /// we need to instantiate an algorithm configured to satisfy the rule's configuration - /// - /// - /// - /// - /// - private ConcurrentDictionary GenerateAlgorithmsFromRules(IEnumerable rules) - { - var values = new ConcurrentDictionary(); - - var algorithms = new ConcurrentDictionary(); - - foreach (var rule in rules) - { - // TODO: Clean up this mess! - switch (rule.Type) - { - case LimiterType.RequestsPerTimespan: - - if (rule is not RequestPerTimespanRule typedRule) - throw new InvalidCastException("uh oh"); - - // do we have an algorithm that meets these requirements? - var rptKey = $"{typedRule.Algorithm}|{typedRule.MaxRequests}|{typedRule.TimespanMilliseconds}"; - - if (!algorithms.TryGetValue(rptKey, out var existingAlgo)) - { - // create the required algo with the required config - var algo = _algorithmProvider.GetAlgorithm( - typedRule.Algorithm, - typedRule.MaxRequests, - typedRule.TimespanMilliseconds); - values.TryAdd(typedRule.Name, algo); - algorithms.TryAdd(rptKey, algo); - } - else - { - values.TryAdd(typedRule.Name, existingAlgo); - } - - break; - case LimiterType.TimespanElapsed: - - if (rule is not TimespanElapsedRule teRule) - throw new InvalidCastException("uh oh"); - - // do we have an algorithm that meets these requirements? - var teKey = $"{teRule.Algorithm}|{teRule.TimespanSinceMilliseconds}"; - - if (!algorithms.TryGetValue(teKey, out var existingTeAlgo)) - { - // create the required algo with the required config - var algo = _algorithmProvider.GetAlgorithm( - teRule.Algorithm, - null, - teRule.TimespanSinceMilliseconds); - values.TryAdd(teRule.Name, algo); - algorithms.TryAdd(teKey, algo); - } - else - { - values.TryAdd(teRule.Name, existingTeAlgo); - } - - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - return values; - } } \ No newline at end of file diff --git a/RateLimiter/RateLimiterRulesFactory.cs b/RateLimiter/RateLimiterRulesFactory.cs deleted file mode 100644 index e818ef31..00000000 --- a/RateLimiter/RateLimiterRulesFactory.cs +++ /dev/null @@ -1,60 +0,0 @@ -using RateLimiter.Abstractions; -using RateLimiter.Config; -using RateLimiter.Enums; -using RateLimiter.Rules; - -using System; -using System.Collections.Generic; - -namespace RateLimiter; - -public class RateLimiterRulesFactory : IProvideRateLimitRules -{ - public IEnumerable GetRules(RateLimiterConfiguration configuration) - { - var rules = new List(); - - // Load rules defined via appSettings - foreach (var rule in configuration.Rules) - { - switch (rule.Type) - { - case LimiterType.RequestsPerTimespan: - rules.Add(new RequestPerTimespanRule() - { - Name = rule.Name, - Algorithm = rule.Algorithm is null or RateLimitingAlgorithm.Default ? configuration.DefaultAlgorithm : rule.Algorithm.Value, - Discriminator = rule.Discriminator, - CustomDiscriminatorName = rule.CustomDiscriminatorType, - DiscriminatorMatch = rule.DiscriminatorMatch, - DiscriminatorKey = rule.DiscriminatorKey, - MaxRequests = rule.MaxRequests ?? configuration.DefaultMaxRequests, - TimespanMilliseconds = rule.TimespanMilliseconds is null ? - TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : - TimeSpan.FromMilliseconds(rule.TimespanMilliseconds.Value) - }); - break; - case LimiterType.TimespanElapsed: - rules.Add(new TimespanElapsedRule() - { - Name = rule.Name, - Algorithm = rule.Algorithm is null or RateLimitingAlgorithm.Default ? configuration.DefaultAlgorithm : rule.Algorithm.Value, - Discriminator = rule.Discriminator, - CustomDiscriminatorName = rule.CustomDiscriminatorType, - DiscriminatorMatch = rule.DiscriminatorMatch, - DiscriminatorKey = rule.DiscriminatorKey, - TimespanSinceMilliseconds = rule.TimespanMilliseconds is null ? - TimeSpan.FromMilliseconds(configuration.DefaultTimespanMilliseconds) : - TimeSpan.FromMilliseconds(rule.TimespanMilliseconds.Value) - }); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - // TODO: Load user-defined rules? Not sure if we will do that. User-defined Discriminators, sure - but not a rule ... - - return rules; - } -} \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs b/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs index c715e88b..0b17fde3 100644 --- a/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs +++ b/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs @@ -5,10 +5,11 @@ using RateLimiter.Enums; using System; +using System.Collections.Concurrent; namespace RateLimiter.Rules.Algorithms { - public class AlgorithmProvider : IProvideRateLimitAlgorithms + public class AlgorithmProvider : IRateLimitAlgorithmProvider { private readonly IDateTimeProvider _dateTimeProvider; private readonly IOptions _options; @@ -21,45 +22,76 @@ public AlgorithmProvider( _options = options; } - public IAmARateLimitAlgorithm GetAlgorithm( - RateLimitingAlgorithm algo, - int? maxRequests, - TimeSpan? timespanMilliseconds) + + public ConcurrentDictionary + GenerateAlgorithmsFromRules(RateLimiterConfiguration configuration) { - return algo switch + var algorithms = new ConcurrentDictionary(); + + foreach (var algo in configuration.Algorithms) { - RateLimitingAlgorithm.Default or RateLimitingAlgorithm.FixedWindow => new FixedWindow(_dateTimeProvider, - new FixedWindowConfiguration() - { - MaxRequests = maxRequests ?? _options.Value.DefaultMaxRequests, - WindowDuration = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) - }), - RateLimitingAlgorithm.TokenBucket => new TokenBucket(_dateTimeProvider, - new TokenBucketConfiguration() - { - // TODO: Move to config - MaxTokens = 10, - RefillRatePerSecond = 10 - }), - RateLimitingAlgorithm.LeakyBucket => new LeakyBucket(_dateTimeProvider, - new LeakyBucketConfiguration() - { - Capacity = maxRequests ?? _options.Value.DefaultMaxRequests, - Interval = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) - }), - RateLimitingAlgorithm.SlidingWindow => new SlidingWindow(_dateTimeProvider, - new SlidingWindowConfiguration() - { - MaxRequests = maxRequests ?? _options.Value.DefaultMaxRequests, - WindowDuration = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) - }), - RateLimitingAlgorithm.TimespanElapsed => new TimespanElapsed(_dateTimeProvider, - new TimespanElapsedConfiguration() - { - MinInterval = timespanMilliseconds ?? TimeSpan.FromMilliseconds(_options.Value.DefaultTimespanMilliseconds) - }), - _ => throw new ArgumentOutOfRangeException(nameof(algo), algo, null) - }; + switch (algo.Type) + { + case AlgorithmType.FixedWindow: + if (!algorithms.TryGetValue(algo.Name, out var existingAlgo)) + { + algorithms.TryAdd(algo.Name, new FixedWindow(_dateTimeProvider, + new FixedWindowConfiguration() + { + MaxRequests = algo.Parameters.MaxRequests.Value, + WindowDuration = TimeSpan.FromMilliseconds(algo.Parameters.WindowDurationMS.Value) + })); + } + break; + case AlgorithmType.LeakyBucket: + if (!algorithms.TryGetValue(algo.Name, out var existingLeaky)) + { + algorithms.TryAdd(algo.Name, new LeakyBucket(_dateTimeProvider, + new LeakyBucketConfiguration() + { + Capacity = algo.Parameters.Capacity.Value, + Interval = TimeSpan.FromMilliseconds(algo.Parameters.IntervalMS.Value) + })); + } + break; + case AlgorithmType.SlidingWindow: + if (!algorithms.TryGetValue(algo.Name, out var existingSliding)) + { + algorithms.TryAdd(algo.Name, new SlidingWindow(_dateTimeProvider, + new SlidingWindowConfiguration() + { + MaxRequests = algo.Parameters.MaxRequests.Value, + WindowDuration = TimeSpan.FromMilliseconds(algo.Parameters.WindowDurationMS.Value) + })); + } + break; + case AlgorithmType.TimespanElapsed: + if (!algorithms.TryGetValue(algo.Name, out var existingTSElapsed)) + { + algorithms.TryAdd(algo.Name, new TimespanElapsed(_dateTimeProvider, + new TimespanElapsedConfiguration() + { + MinInterval = TimeSpan.FromMilliseconds(algo.Parameters.MinIntervalMS.Value) + })); + } + break; + case AlgorithmType.TokenBucket: + if (!algorithms.TryGetValue(algo.Name, out var existingTokenBucket)) + { + algorithms.TryAdd(algo.Name, new TokenBucket(_dateTimeProvider, + new TokenBucketConfiguration() + { + RefillRatePerSecond = algo.Parameters.RefillRatePerSecond.Value, + MaxTokens = algo.Parameters.MaxTokens.Value + })); + } + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + return algorithms; } } } diff --git a/RateLimiter/Rules/Algorithms/FixedWindow.cs b/RateLimiter/Rules/Algorithms/FixedWindow.cs index 6e4c91b0..d19c1b26 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindow.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindow.cs @@ -6,7 +6,7 @@ namespace RateLimiter.Rules.Algorithms; -public class FixedWindow : IAmARateLimitAlgorithm +public class FixedWindow : IRateLimitAlgorithm { private readonly IDateTimeProvider _dateTimeProvider; private readonly int _maxRequests; @@ -42,5 +42,5 @@ public bool IsAllowed(string discriminator) return window.Count <= _maxRequests; } - public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.FixedWindow; + public AlgorithmType AlgorithmType { get; init; } = AlgorithmType.FixedWindow; } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs b/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs index 1c6f2093..9ab45fb8 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs +++ b/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs @@ -1,10 +1,11 @@ -using System; +using RateLimiter.Abstractions; + +using System; namespace RateLimiter.Rules.Algorithms; -public record FixedWindowConfiguration +public record FixedWindowConfiguration : IRateLimitAlgorithmConfiguration { - public int MaxRequests { get; init; } public TimeSpan WindowDuration { get; init; } diff --git a/RateLimiter/Rules/Algorithms/LeakyBucket.cs b/RateLimiter/Rules/Algorithms/LeakyBucket.cs index 948c02fa..5f64b711 100644 --- a/RateLimiter/Rules/Algorithms/LeakyBucket.cs +++ b/RateLimiter/Rules/Algorithms/LeakyBucket.cs @@ -6,7 +6,7 @@ namespace RateLimiter.Rules.Algorithms; -public class LeakyBucket : IAmARateLimitAlgorithm +public class LeakyBucket : IRateLimitAlgorithm { private readonly int _capacity; private readonly TimeSpan _leakInterval; @@ -53,7 +53,7 @@ public bool IsAllowed(string discriminator) } } - public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.LeakyBucket; + public AlgorithmType AlgorithmType { get; init; } = AlgorithmType.LeakyBucket; private class BucketState { diff --git a/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs b/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs index 83ddefe1..97e9bee7 100644 --- a/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs +++ b/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs @@ -1,8 +1,10 @@ -using System; +using RateLimiter.Abstractions; + +using System; namespace RateLimiter.Rules.Algorithms { - public class LeakyBucketConfiguration + public class LeakyBucketConfiguration : IRateLimitAlgorithmConfiguration { public int Capacity { get; init; } diff --git a/RateLimiter/Rules/Algorithms/SlidingWindow.cs b/RateLimiter/Rules/Algorithms/SlidingWindow.cs index b5ad6b76..d5ffe1e5 100644 --- a/RateLimiter/Rules/Algorithms/SlidingWindow.cs +++ b/RateLimiter/Rules/Algorithms/SlidingWindow.cs @@ -7,7 +7,7 @@ namespace RateLimiter.Rules.Algorithms; -public class SlidingWindow : IAmARateLimitAlgorithm +public class SlidingWindow : IRateLimitAlgorithm { private readonly int _maxRequests; private readonly TimeSpan _windowDuration; @@ -42,5 +42,5 @@ public bool IsAllowed(string discriminator) } } - public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.SlidingWindow; + public AlgorithmType AlgorithmType { get; init; } = AlgorithmType.SlidingWindow; } \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs b/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs index e6dac69a..bc03d6eb 100644 --- a/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs +++ b/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs @@ -1,8 +1,10 @@ -using System; +using RateLimiter.Abstractions; + +using System; namespace RateLimiter.Rules.Algorithms { - public class SlidingWindowConfiguration + public class SlidingWindowConfiguration : IRateLimitAlgorithmConfiguration { public int MaxRequests { get; init; } diff --git a/RateLimiter/Rules/Algorithms/TimespanElapsed.cs b/RateLimiter/Rules/Algorithms/TimespanElapsed.cs index 37e286f0..346b2885 100644 --- a/RateLimiter/Rules/Algorithms/TimespanElapsed.cs +++ b/RateLimiter/Rules/Algorithms/TimespanElapsed.cs @@ -6,7 +6,7 @@ namespace RateLimiter.Rules.Algorithms { - public class TimespanElapsed : IAmARateLimitAlgorithm + public class TimespanElapsed : IRateLimitAlgorithm { private readonly TimeSpan _minInterval; private readonly IDateTimeProvider _dateTimeProvider; @@ -36,11 +36,6 @@ public bool IsAllowed(string discriminator) return true; } - public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.TimespanElapsed; - } - - public class TimespanElapsedConfiguration - { - public TimeSpan MinInterval { get; set; } + public AlgorithmType AlgorithmType { get; init; } = AlgorithmType.TimespanElapsed; } } diff --git a/RateLimiter/Rules/Algorithms/TimespanElapsedConfiguration.cs b/RateLimiter/Rules/Algorithms/TimespanElapsedConfiguration.cs new file mode 100644 index 00000000..d37348b4 --- /dev/null +++ b/RateLimiter/Rules/Algorithms/TimespanElapsedConfiguration.cs @@ -0,0 +1,8 @@ +using System; + +namespace RateLimiter.Rules.Algorithms; + +public class TimespanElapsedConfiguration +{ + public TimeSpan MinInterval { get; set; } +} \ No newline at end of file diff --git a/RateLimiter/Rules/Algorithms/TokenBucket.cs b/RateLimiter/Rules/Algorithms/TokenBucket.cs index e684e69f..be55c71e 100644 --- a/RateLimiter/Rules/Algorithms/TokenBucket.cs +++ b/RateLimiter/Rules/Algorithms/TokenBucket.cs @@ -6,7 +6,7 @@ namespace RateLimiter.Rules.Algorithms; -public class TokenBucket : IAmARateLimitAlgorithm +public class TokenBucket : IRateLimitAlgorithm { private readonly int _maxTokens; private readonly int _refillRatePerSecond; // Tokens added per second @@ -47,7 +47,7 @@ public bool IsAllowed(string discriminator) } } - public RateLimitingAlgorithm Algorithm { get; init; } = RateLimitingAlgorithm.TokenBucket; + public AlgorithmType AlgorithmType { get; init; } = AlgorithmType.TokenBucket; private class BucketState { diff --git a/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs b/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs index a77442ed..9ff4e111 100644 --- a/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs +++ b/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs @@ -1,6 +1,8 @@ -namespace RateLimiter.Rules.Algorithms +using RateLimiter.Abstractions; + +namespace RateLimiter.Rules.Algorithms { - public class TokenBucketConfiguration + public class TokenBucketConfiguration : IRateLimitAlgorithmConfiguration { public int MaxTokens { get; set; } diff --git a/RateLimiter/Rules/RateLimitRule.cs b/RateLimiter/Rules/RateLimitRule.cs new file mode 100644 index 00000000..5e82dfa9 --- /dev/null +++ b/RateLimiter/Rules/RateLimitRule.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public class RateLimitRule + { + public string Name { get; set; } + + + } +} diff --git a/RateLimiter/Rules/RequestPerTimespanRule.cs b/RateLimiter/Rules/RequestPerTimespanRule.cs deleted file mode 100644 index 399f341c..00000000 --- a/RateLimiter/Rules/RequestPerTimespanRule.cs +++ /dev/null @@ -1,27 +0,0 @@ -using RateLimiter.Abstractions; -using RateLimiter.Enums; - -using System; - -namespace RateLimiter.Rules -{ - public class RequestPerTimespanRule : IDefineARateLimitRule - { - public LimiterType Type { get; } = LimiterType.RequestsPerTimespan; - - public string Name { get; set; } - - public LimiterDiscriminator Discriminator { get; set; } - public string? CustomDiscriminatorName { get; set; } - - public string? DiscriminatorKey { get; set; } - - public string? DiscriminatorMatch { get; set; } - - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.Default; - - public int MaxRequests { get; set; } - - public TimeSpan TimespanMilliseconds { get; set; } - } -} diff --git a/RateLimiter/Rules/TimespanElapsedRule.cs b/RateLimiter/Rules/TimespanElapsedRule.cs deleted file mode 100644 index 67fcddc6..00000000 --- a/RateLimiter/Rules/TimespanElapsedRule.cs +++ /dev/null @@ -1,27 +0,0 @@ -using RateLimiter.Abstractions; -using RateLimiter.Enums; - -using System; - -namespace RateLimiter.Rules -{ - public class TimespanElapsedRule : IDefineARateLimitRule - { - public LimiterType Type { get; } = LimiterType.TimespanElapsed; - - public string Name { get; set; } - - public LimiterDiscriminator Discriminator { get; set; } - - public string? CustomDiscriminatorName { get; set; } - - public string? DiscriminatorKey { get; set; } - - public string? DiscriminatorMatch { get; set; } - - // TODO: Restrict the options for setting an algorithm? - public RateLimitingAlgorithm Algorithm { get; set; } = RateLimitingAlgorithm.Default; - - public TimeSpan TimespanSinceMilliseconds { get; set; } - } -} From 2d76f542df8d9167f93afbc0484cf271eec48987 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Sat, 15 Feb 2025 10:46:41 -0500 Subject: [PATCH 23/29] prep for distributed caching --- .../DiscriminatorProviderTests.cs | 98 ++---- RateLimiter.Tests/RateLimiterTest.cs | 283 +++++++++--------- .../Discriminators/IpAddressDiscriminator.cs | 9 +- RateLimiter/RateLimiter.cs | 5 +- 4 files changed, 172 insertions(+), 223 deletions(-) diff --git a/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs b/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs index 6bfd678f..463dbf2a 100644 --- a/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs +++ b/RateLimiter.Tests/Discriminators/DiscriminatorProviderTests.cs @@ -1,82 +1,32 @@ -//using FluentAssertions; +using FluentAssertions; -//using HttpContextMoq; -//using HttpContextMoq.Extensions; +using RateLimiter.Config; +using RateLimiter.Discriminators; -//using Microsoft.Extensions.Primitives; +using System.Collections.Generic; -//using RateLimiter.Abstractions; -//using RateLimiter.Discriminators; -//using RateLimiter.Enums; -//using RateLimiter.Rules; +using Xunit; -//using System; -//using System.Collections.Generic; +namespace RateLimiter.Tests.Discriminators +{ + public class DiscriminatorProviderTests : UnitTestBase + { + [Fact] + public void GenerateDiscriminators_OnValidData_GeneratesDiscriminators() + { + // arrange + var config = new List() + { -//using Xunit; + }; -//namespace RateLimiter.Tests.Discriminators -//{ -// public class DiscriminatorProviderTests : UnitTestBase -// { -// [Fact] -// public void GetDiscriminatorValues_OnValidData_GetsValues() -// { -// // arrange -// var context = new HttpContextMock() -// .SetupUrl("http://localhost:8000/path") -// .SetupRequestHeaders(new Dictionary() -// { -// { "Host", "192.168.0.1"} -// }) -// .SetupRequestMethod("GET"); + var sut = Mocker.CreateInstance(); -// var sut = Mocker.CreateInstance(); + // act + var result = sut.GenerateDiscriminators(config); -// // act -// var result = sut.GetDiscriminatorValues(context, rules); - -// // assert -// result.Count.Should().Be(rules.Count); -// } - -// private List rules = -// [ -// new RequestPerTimespanRule() -// { -// AlgorithmType = AlgorithmType.FixedWindow, -// CustomDiscriminatorName = string.Empty, -// Discriminator = DiscriminatorType.QueryString, -// DiscriminatorMatch = "someQuerystringValue", -// DiscriminatorKey = string.Empty, -// MaxRequests = 5, -// Name = $"My{nameof(DiscriminatorType.QueryString)}", -// TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) -// }, - -// new RequestPerTimespanRule() -// { -// AlgorithmType = AlgorithmType.FixedWindow, -// CustomDiscriminatorName = string.Empty, -// Discriminator = DiscriminatorType.RequestHeader, -// DiscriminatorMatch = string.Empty, -// DiscriminatorKey = "Host", -// MaxRequests = 5, -// Name = $"My{nameof(DiscriminatorType.RequestHeader)}", -// TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) -// }, - -// new RequestPerTimespanRule() -// { -// AlgorithmType = AlgorithmType.FixedWindow, -// CustomDiscriminatorName = string.Empty, -// Discriminator = DiscriminatorType.IpAddress, -// DiscriminatorMatch = string.Empty, -// DiscriminatorKey = string.Empty, -// MaxRequests = 5, -// Name = $"My{nameof(DiscriminatorType.IpAddress)}", -// TimespanMilliseconds = TimeSpan.FromMilliseconds(1000) -// } -// ]; -// } -//} + // assert + result.Count.Should().Be(config.Count); + } + } +} diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 9c20e21e..479b8afc 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,143 +1,142 @@  -//using AutoFixture; - -//using FluentAssertions; - -//using HttpContextMoq; -//using HttpContextMoq.Extensions; - -//using Microsoft.Extensions.Options; -//using Microsoft.Extensions.Primitives; - -//using Moq.AutoMock; - -//using RateLimiter.Abstractions; -//using RateLimiter.Common; -//using RateLimiter.Config; -//using RateLimiter.Discriminators; -//using RateLimiter.Enums; -//using RateLimiter.Rules.Algorithms; - -//using System.Collections.Generic; -//using System.Threading; - -//using Xunit; - -//using static RateLimiter.Config.RateLimiterConfiguration; - -//namespace RateLimiter.Tests; - -//public class RateLimiterTest -//{ -// /// -// /// Note: This test class is not true unit testing and would not exist in this project -// /// Chose to use concrete implementations in some places to facilitate functional testing -// /// -// [Fact] -// public void IsRequestAllowed() -// { -// var mocker = new AutoMocker(); -// var fixture = new Fixture(); - -// // arrange -// var appOptions = Options.Create(new RateLimiterConfiguration() -// { -// DefaultAlgorithmType = AlgorithmType.FixedWindow, -// DefaultMaxRequests = 5, -// DefaultTimespanMilliseconds = 3000, -// Rules = GenerateRateLimitRules() -// }); - -// mocker.GetMock>() -// .Setup(s => s.Value) -// .Returns(appOptions.Value); - -// mocker.Use(new DateTimeProvider()); -// mocker.Use(new DiscriminatorProvider(null, null)); - -// //// mock the rules as would be defined within appSettings -// //var rateLimitRules = GenerateRateLimitRules(); -// //mocker.GetMock() -// // .Setup(s => s.GetRules(new RateLimiterConfiguration())) -// // .Returns(rateLimitRules); -// mocker.Use(new RateLimiterRulesFactory()); - -// // mock the rule attribute as would be applied to our resource's endpoint -// var rateLimitedResources = new List() -// { -// fixture.Build() -// .With(x => x.RuleName, "RequestPerTimespan-Default") -// .Create() -// }; - -// var context = new HttpContextMock() -// .SetupUrl("http://localhost:8000/path") -// .SetupRequestHeaders(new Dictionary() -// { -// { "Host", "192.168.0.1"} -// }) -// .SetupRequestMethod("GET"); - -// var algoProvider = mocker.CreateInstance(); -// mocker.Use(algoProvider); - -// var limiter = mocker.CreateInstance(); - -// // act -// const int numberOfRequestsToTry = 4; - -// for (var i = 0; i < numberOfRequestsToTry; i++) -// { -// var result = limiter.IsRequestAllowed(context, rateLimitedResources); - -// // assert -// if (i <= 2) -// { -// result.RequestIsAllowed.Should().BeTrue(); -// result.ErrorMessage.Should().BeNullOrEmpty(); -// } -// else -// { -// result.RequestIsAllowed.Should().BeFalse(); -// result.ErrorMessage.Should().NotBeNullOrEmpty(); - -// // wait 3 seconds -// Thread.Sleep(3000); - -// result = limiter.IsRequestAllowed(context, rateLimitedResources); - -// result.RequestIsAllowed.Should().BeTrue(); -// result.ErrorMessage.Should().BeNullOrEmpty(); -// } -// } -// } - -// private static List GenerateRateLimitRules() -// { -// var fixture = new Fixture(); -// var values = new List -// { -// fixture.Build() -// .With(x => x.Name, "RequestPerTimespan-Default") -// .With(x => x.Type, LimiterType.RequestsPerTimespan) -// .With(x => x.Discriminator, DiscriminatorType.IpAddress) -// .With(x => x.DiscriminatorMatch, string.Empty) -// .With(x => x.DiscriminatorKey, string.Empty) -// .With(x => x.MaxRequests, 3) -// .With(x => x.TimespanMilliseconds, 3000) -// .With(x => x.Algorithm, AlgorithmType.Default) -// .Create(), -// fixture.Build() -// .With(x => x.Name, "ApiKey-Default") -// .With(x => x.Type, LimiterType.RequestsPerTimespan) -// .With(x => x.Discriminator, DiscriminatorType.QueryString) -// .With(x => x.DiscriminatorMatch, "x-crexi-token") -// .With(x => x.DiscriminatorKey, "US") -// .With(x => x.Algorithm, AlgorithmType.Default) -// .With(x => x.TimespanMilliseconds, 4000) -// .Create() -// }; - -// return values; -// } -//} \ No newline at end of file +using AutoFixture; + +using FluentAssertions; + +using HttpContextMoq; +using HttpContextMoq.Extensions; + +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +using Moq.AutoMock; + +using RateLimiter.Abstractions; +using RateLimiter.Common; +using RateLimiter.Config; +using RateLimiter.Discriminators; +using RateLimiter.Enums; +using RateLimiter.Rules.Algorithms; + +using System.Collections.Generic; +using System.Threading; + +using Xunit; + +using static RateLimiter.Config.RateLimiterConfiguration; + +namespace RateLimiter.Tests; + +public class RateLimiterTest +{ + /// + /// Note: This test class is not true unit testing and would not exist in this project + /// Chose to use concrete implementations in some places to facilitate functional testing + /// + [Fact] + public void IsRequestAllowed() + { + var mocker = new AutoMocker(); + var fixture = new Fixture(); + + // arrange + var appOptions = Options.Create(new RateLimiterConfiguration() + { + Algorithms = + [ + new AlgorithmConfiguration() + { + Name = "RequestsPerTimeSpan0", + Parameters = new AlgorithmConfiguration.AlgorithmConfigurationParameters() + { + MaxRequests = 3, + WindowDurationMS = 3000 + }, + Type = AlgorithmType.FixedWindow + }, + new AlgorithmConfiguration() + { + Name = "TimeSpanElapsed0", + Parameters = new AlgorithmConfiguration.AlgorithmConfigurationParameters() + { + MinIntervalMS = 3000 + }, + Type = AlgorithmType.TimespanElapsed + } + ], + Rules = + [ + new RuleConfiguration() + { + Name = "IpAddressRule", + Discriminators = ["IpAddressDisc"] + } + ], + Discriminators = + [ + new DiscriminatorConfiguration() + { + Name = "IpAddressDisc", + Type = DiscriminatorType.IpAddress, + AlgorithmNames = ["RequestsPerTimeSpan0"] + } + ] + }); + + mocker.GetMock>() + .Setup(s => s.Value) + .Returns(appOptions.Value); + + mocker.Use(new DateTimeProvider()); + mocker.Use(new DiscriminatorProvider(null, null)); + + // mock the rule attribute as would be applied to our resource's endpoint + var rateLimitedResources = new List() + { + fixture.Build() + .With(x => x.RuleName, "IpAddressRule") + .Create() + }; + + var context = new HttpContextMock() + .SetupUrl("http://localhost:8000/path") + .SetupRequestHeaders(new Dictionary() + { + { "Host", "192.168.0.1"} + }) + .SetupRequestMethod("GET"); + + var algoProvider = mocker.CreateInstance(); + mocker.Use(algoProvider); + + var limiter = mocker.CreateInstance(); + + // act + const int numberOfRequestsToTry = 4; + + for (var i = 0; i < numberOfRequestsToTry; i++) + { + var result = limiter.IsRequestAllowed(context, rateLimitedResources); + + // assert + if (i <= 2) + { + result.RequestIsAllowed.Should().BeTrue(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + else + { + result.RequestIsAllowed.Should().BeFalse(); + result.ErrorMessage.Should().NotBeNullOrEmpty(); + + // wait 3 seconds + Thread.Sleep(3000); + + result = limiter.IsRequestAllowed(context, rateLimitedResources); + + result.RequestIsAllowed.Should().BeTrue(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + } + } +} \ No newline at end of file diff --git a/RateLimiter/Discriminators/IpAddressDiscriminator.cs b/RateLimiter/Discriminators/IpAddressDiscriminator.cs index 31a8fde1..5621a22a 100644 --- a/RateLimiter/Discriminators/IpAddressDiscriminator.cs +++ b/RateLimiter/Discriminators/IpAddressDiscriminator.cs @@ -20,19 +20,22 @@ public DiscriminatorEvaluationResult Evaluate(HttpContext context) return new DiscriminatorEvaluationResult(configuration.Name) { IsMatch = true, - MatchValue = ipAddress + MatchValue = ipAddress, + AlgorithmName = configuration.AlgorithmNames[0] }; return configuration.DiscriminatorMatch == ipAddress ? new DiscriminatorEvaluationResult(configuration.Name) { IsMatch = true, - MatchValue = ipAddress + MatchValue = ipAddress, + AlgorithmName = configuration.AlgorithmNames[0] } : new DiscriminatorEvaluationResult(configuration.Name) { IsMatch = false, - MatchValue = ipAddress + MatchValue = ipAddress, + AlgorithmName = configuration.AlgorithmNames[0] }; } } diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index d43a5f05..7b2513aa 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -6,10 +6,8 @@ using RateLimiter.Config; using RateLimiter.Discriminators; -using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Data; using System.Linq; using static RateLimiter.Config.RateLimiterConfiguration; @@ -37,8 +35,7 @@ public RateLimiter( ILogger logger, IOptions options, IRateLimitDiscriminatorProvider discriminatorsProvider, - IRateLimitAlgorithmProvider algorithmProvider - ) + IRateLimitAlgorithmProvider algorithmProvider) { _logger = logger; _algorithmProvider = algorithmProvider; From 07711b7dba55f6b49333e726fe9e2e5d26a9570c Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Sun, 16 Feb 2025 06:12:47 -0500 Subject: [PATCH 24/29] configuration validation and test --- .../RateLimiterConfigurationValidatorTests.cs | 44 +++++++++++ .../IRateLimiterConfigurationValidator.cs | 11 +++ .../RateLimiterConfigurationValidator.cs | 77 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 RateLimiter.Tests/Config/RateLimiterConfigurationValidatorTests.cs create mode 100644 RateLimiter/Abstractions/IRateLimiterConfigurationValidator.cs create mode 100644 RateLimiter/Config/RateLimiterConfigurationValidator.cs diff --git a/RateLimiter.Tests/Config/RateLimiterConfigurationValidatorTests.cs b/RateLimiter.Tests/Config/RateLimiterConfigurationValidatorTests.cs new file mode 100644 index 00000000..73a706d5 --- /dev/null +++ b/RateLimiter.Tests/Config/RateLimiterConfigurationValidatorTests.cs @@ -0,0 +1,44 @@ +using AutoFixture; + +using FluentAssertions; + +using RateLimiter.Config; +using RateLimiter.Enums; + +using Xunit; + +namespace RateLimiter.Tests.Config +{ + public class RateLimiterConfigurationValidatorTests : UnitTestBase + { + [Fact] + public void Validate_WhenMissingAlgorithmConfigurationVars_FailsValidation() + { + // arrange + var config = Fixture.Build() + .With(x => x.Algorithms, [ + Fixture.Build() + .With(x => x.Type, AlgorithmType.FixedWindow) + .With(x => x.Parameters, + new RateLimiterConfiguration.AlgorithmConfiguration.AlgorithmConfigurationParameters() + { + MaxRequests = 5, + WindowDurationMS = null + }) + .OmitAutoProperties() + .Create() + ]) + .OmitAutoProperties() + .Create(); + + var validator = Mocker.CreateInstance(); + + // act + var result = validator.Validate(config); + + // assert + result.IsValid.Should().BeFalse(); + result.Errors.Count.Should().Be(1); + } + } +} diff --git a/RateLimiter/Abstractions/IRateLimiterConfigurationValidator.cs b/RateLimiter/Abstractions/IRateLimiterConfigurationValidator.cs new file mode 100644 index 00000000..5a03fa67 --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimiterConfigurationValidator.cs @@ -0,0 +1,11 @@ +using RateLimiter.Config; + +using System.Collections.Generic; + +namespace RateLimiter.Abstractions +{ + public interface IRateLimiterConfigurationValidator + { + (bool IsValid, List Errors) Validate(RateLimiterConfiguration configuration); + } +} diff --git a/RateLimiter/Config/RateLimiterConfigurationValidator.cs b/RateLimiter/Config/RateLimiterConfigurationValidator.cs new file mode 100644 index 00000000..334e8b98 --- /dev/null +++ b/RateLimiter/Config/RateLimiterConfigurationValidator.cs @@ -0,0 +1,77 @@ +using RateLimiter.Abstractions; +using RateLimiter.Enums; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Config +{ + public class RateLimiterConfigurationValidator : IRateLimiterConfigurationValidator + { + public (bool IsValid, List Errors) Validate(RateLimiterConfiguration configuration) + { + var messages = new List(); + + foreach (var algorithm in configuration.Algorithms) + { + switch (algorithm.Type) + { + case AlgorithmType.FixedWindow: + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.MaxRequests)}"); + if (algorithm.Parameters.WindowDurationMS is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.WindowDurationMS)}"); + break; + case AlgorithmType.LeakyBucket: + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.Capacity)}"); + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.IntervalMS)}"); + break; + case AlgorithmType.SlidingWindow: + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.MaxRequests)}"); + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.WindowDurationMS)}"); + break; + case AlgorithmType.TimespanElapsed: + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.MinIntervalMS)}"); + break; + case AlgorithmType.TokenBucket: + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.MaxTokens)}"); + if (algorithm.Parameters.MaxRequests is null) + messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.RefillRatePerSecond)}"); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + foreach (var discriminator in configuration.Discriminators) + { + foreach (var algorithmName in discriminator.AlgorithmNames) + { + var exists = configuration.Algorithms.Any(x => x.Name == algorithmName); + if (!exists) + messages.Add($"Discriminator {discriminator.Name} references an algorithm named {algorithmName}, but it does not exist in the configured algorithms"); + } + } + + foreach (var rule in configuration.Rules) + { + foreach (var discriminatorName in rule.Discriminators) + { + var exists = configuration.Discriminators.Any(x => x.Name == discriminatorName); + if (!exists) + messages.Add($"Rule {rule.Name} references a discriminator named {discriminatorName}, but it does not exist in the configured discriminators"); + } + } + + return (messages.Count == 0, messages); + + } + } +} From 8a2f3ad63347c0a861579751a86b91903201cfb9 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Tue, 18 Feb 2025 07:56:23 -0500 Subject: [PATCH 25/29] added integration test for threading checks within rateLimiter --- .../RateLimiting/GeoTokenDiscriminator.cs | 5 +- RateLimiter.Tests.Api/Program.cs | 2 - .../RateLimiter.Tests.Api.csproj | 3 + .../RateLimiter.Tests.Integration.csproj | 21 +++++ .../WeatherForecastControllerTests.cs | 88 +++++++++++++++++++ RateLimiter.sln | 7 ++ RateLimiter.sln.DotSettings.user | 11 ++- RateLimiter/RateLimiter.cs | 3 +- 8 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 RateLimiter.Tests.Integration/RateLimiter.Tests.Integration.csproj create mode 100644 RateLimiter.Tests.Integration/WeatherForecastControllerTests.cs diff --git a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs index 17aed2b5..185f82b6 100644 --- a/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs +++ b/RateLimiter.Tests.Api/Middleware/RateLimiting/GeoTokenDiscriminator.cs @@ -19,7 +19,10 @@ public DiscriminatorEvaluationResult Evaluate(HttpContext context) { if (!context.Request.Headers.TryGetValue("x-crexi-token", out var value)) { - return new DiscriminatorEvaluationResult(Configuration.Name); + return new DiscriminatorEvaluationResult(Configuration.Name) + { + IsMatch = false + }; } return value.ToString().StartsWith("US") ? diff --git a/RateLimiter.Tests.Api/Program.cs b/RateLimiter.Tests.Api/Program.cs index 4a889cd2..292bbc41 100644 --- a/RateLimiter.Tests.Api/Program.cs +++ b/RateLimiter.Tests.Api/Program.cs @@ -21,8 +21,6 @@ app.UseSwaggerUI(); } -app.UseHttpsRedirection(); - app.UseAuthorization(); app.MapControllers(); diff --git a/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj index 9331526c..a96a4a9a 100644 --- a/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj +++ b/RateLimiter.Tests.Api/RateLimiter.Tests.Api.csproj @@ -14,5 +14,8 @@ + + + diff --git a/RateLimiter.Tests.Integration/RateLimiter.Tests.Integration.csproj b/RateLimiter.Tests.Integration/RateLimiter.Tests.Integration.csproj new file mode 100644 index 00000000..dce47304 --- /dev/null +++ b/RateLimiter.Tests.Integration/RateLimiter.Tests.Integration.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/RateLimiter.Tests.Integration/WeatherForecastControllerTests.cs b/RateLimiter.Tests.Integration/WeatherForecastControllerTests.cs new file mode 100644 index 00000000..7ae643d5 --- /dev/null +++ b/RateLimiter.Tests.Integration/WeatherForecastControllerTests.cs @@ -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(); + var client = factory.CreateClient(); + var response = await client.GetAsync("WeatherForecast"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + /// + /// Represents many unique clients (US and EU) making many calls to our API + /// + /// + [Fact] + public async Task GetAsync_WhenAllTokensAreUnique_RequestsAreNotRateLimited() + { + // arrange + var factory = new WebApplicationFactory(); + + var tasks = new List>(); + + 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"); + } + + /// + /// Represents two clients (one US and one EU) making many calls to our API + /// + /// + [Fact] + public async Task GetAsync_WhenTokensAreNotUnique_RequestsAreRateLimited() + { + // arrange + var factory = new WebApplicationFactory(); + + var tasks = new List>(); + + 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"); + } + } +} diff --git a/RateLimiter.sln b/RateLimiter.sln index dfb0608c..0e215680 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -21,6 +21,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{5ECAE5DF-8 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests.Api", "RateLimiter.Tests.Api\RateLimiter.Tests.Api.csproj", "{74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests.Integration", "RateLimiter.Tests.Integration\RateLimiter.Tests.Integration.csproj", "{071F6C5E-FA17-474E-A194-E3F314BE0E07}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Debug|Any CPU.Build.0 = Debug|Any CPU {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Release|Any CPU.ActiveCfg = Release|Any CPU {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3}.Release|Any CPU.Build.0 = Release|Any CPU + {071F6C5E-FA17-474E-A194-E3F314BE0E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {071F6C5E-FA17-474E-A194-E3F314BE0E07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {071F6C5E-FA17-474E-A194-E3F314BE0E07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {071F6C5E-FA17-474E-A194-E3F314BE0E07}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -47,6 +53,7 @@ Global {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F} = {EFA099B0-7DF4-40D2-8CAA-92730F7E25BF} {C4F9249B-010E-46BE-94B8-DD20D82F1E60} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} {74A6BC1D-D8A6-4FA0-8D2C-03C8491C74D3} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} + {071F6C5E-FA17-474E-A194-E3F314BE0E07} = {5ECAE5DF-86AA-4264-9C41-AEFF9B9A8292} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {67D05CB6-8603-4C96-97E5-C6CEFBEC6134} diff --git a/RateLimiter.sln.DotSettings.user b/RateLimiter.sln.DotSettings.user index 9bb83526..0f9d4755 100644 --- a/RateLimiter.sln.DotSettings.user +++ b/RateLimiter.sln.DotSettings.user @@ -1,5 +1,14 @@  C:\Users\Randall\AppData\Local\Temp\JetBrains\ReSharperPlatformVs17\vAny_6feb4319\CoverageData\_RateLimiter.1996142771\Snapshot\snapshot.utdcvr <SessionState ContinuousTestingMode="0" IsActive="True" Name="WhenFoo_DoesBar" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="C:\Projects\crexi\rate-limiter\RateLimiter.Tests" Presentation="&lt;test&gt;\&lt;RateLimiter.Tests&gt;" /> + <Or> + <Project Location="C:\Projects\crexi\rate-limiter\RateLimiter.Tests" Presentation="&lt;test&gt;\&lt;RateLimiter.Tests&gt;" /> + <TestAncestor> + <TestId>xUnit::071F6C5E-FA17-474E-A194-E3F314BE0E07::net9.0::RateLimiter.Tests.Integration.WeatherForecastControllerTests.WhenCallingController_ReturnsOk</TestId> + <TestId>xUnit::071F6C5E-FA17-474E-A194-E3F314BE0E07::net9.0::RateLimiter.Tests.Integration.WeatherForecastControllerTests.WhenCallController_RequestHeaderMatches_IsRateLimited</TestId> + <TestId>xUnit::071F6C5E-FA17-474E-A194-E3F314BE0E07::net9.0::RateLimiter.Tests.Integration.WeatherForecastControllerTests.WhenCallController_WhenDistinctTokens_RequestsAreNotRateLimited</TestId> + <TestId>xUnit::071F6C5E-FA17-474E-A194-E3F314BE0E07::net9.0::RateLimiter.Tests.Integration.WeatherForecastControllerTests.WhenCallController_WhenNonDistinctTokens_RequestsAreRateLimited</TestId> + <TestId>xUnit::071F6C5E-FA17-474E-A194-E3F314BE0E07::net9.0::RateLimiter.Tests.Integration.WeatherForecastControllerTests</TestId> + </TestAncestor> + </Or> </SessionState> \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 7b2513aa..030c6234 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -103,9 +103,10 @@ private void ProcessConfiguration(RateLimiterConfiguration configuration) results.Add(x.Evaluate(context)); }); + // lastly, for each of the discriminators, process it with the correct algorithm IFF the discriminator was a match var passed = true; var lastRule = string.Empty; - foreach (var x in results) + foreach (var x in results.Where(r => r.IsMatch)) { lastRule = $"{x.DiscriminatorName}:{x.AlgorithmName}"; passed = _algorithms[x.AlgorithmName].IsAllowed(x.MatchValue); From 206d22f15eff01ba01c1fce8afa1bc702ceb9e1d Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Tue, 18 Feb 2025 08:53:40 -0500 Subject: [PATCH 26/29] add configuration validation --- .../Config/RateLimiterConfigurationValidator.cs | 12 ++++++------ .../DependencyInjection/RateLimiterRegister.cs | 1 + RateLimiter/RateLimiter.cs | 13 ++++++++++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/RateLimiter/Config/RateLimiterConfigurationValidator.cs b/RateLimiter/Config/RateLimiterConfigurationValidator.cs index 334e8b98..85d891bd 100644 --- a/RateLimiter/Config/RateLimiterConfigurationValidator.cs +++ b/RateLimiter/Config/RateLimiterConfigurationValidator.cs @@ -24,25 +24,25 @@ public class RateLimiterConfigurationValidator : IRateLimiterConfigurationValida messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.WindowDurationMS)}"); break; case AlgorithmType.LeakyBucket: - if (algorithm.Parameters.MaxRequests is null) + if (algorithm.Parameters.Capacity is null) messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.Capacity)}"); - if (algorithm.Parameters.MaxRequests is null) + if (algorithm.Parameters.IntervalMS is null) messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.IntervalMS)}"); break; case AlgorithmType.SlidingWindow: if (algorithm.Parameters.MaxRequests is null) messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.MaxRequests)}"); - if (algorithm.Parameters.MaxRequests is null) + if (algorithm.Parameters.WindowDurationMS is null) messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.WindowDurationMS)}"); break; case AlgorithmType.TimespanElapsed: - if (algorithm.Parameters.MaxRequests is null) + if (algorithm.Parameters.MinIntervalMS is null) messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.MinIntervalMS)}"); break; case AlgorithmType.TokenBucket: - if (algorithm.Parameters.MaxRequests is null) + if (algorithm.Parameters.MaxTokens is null) messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.MaxTokens)}"); - if (algorithm.Parameters.MaxRequests is null) + if (algorithm.Parameters.RefillRatePerSecond is null) messages.Add($"Algorithm {algorithm.Name} is of type {algorithm.Type}, but is missing {nameof(algorithm.Parameters.RefillRatePerSecond)}"); break; default: diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs index 42aa3f15..cabe1b9b 100644 --- a/RateLimiter/DependencyInjection/RateLimiterRegister.cs +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -16,6 +16,7 @@ public static class RateLimiterRegister public static IServiceCollection AddRateLimiting(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 030c6234..6bfe80ad 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using System; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -27,6 +28,7 @@ public class RateLimiter : IRateLimiter private readonly IEnumerable _rules; private readonly IRateLimitDiscriminatorProvider _discriminatorsProvider; + private readonly IRateLimiterConfigurationValidator _configurationValidator; private ConcurrentDictionary _algorithms; private ConcurrentDictionary _discriminators; @@ -35,7 +37,8 @@ public RateLimiter( ILogger logger, IOptions options, IRateLimitDiscriminatorProvider discriminatorsProvider, - IRateLimitAlgorithmProvider algorithmProvider) + IRateLimitAlgorithmProvider algorithmProvider, + IRateLimiterConfigurationValidator configurationValidator) { _logger = logger; _algorithmProvider = algorithmProvider; @@ -44,13 +47,17 @@ public RateLimiter( _config = options.Value; _rules = options.Value.Rules; _discriminatorsProvider = discriminatorsProvider; + _configurationValidator = configurationValidator; + ValidateConfiguration(_config); ProcessConfiguration(_config); } private void ValidateConfiguration(RateLimiterConfiguration config) { - return; + var validationResult = _configurationValidator.Validate(config); + if (!validationResult.IsValid) + throw new ApplicationException($"RateLimiter Configuration is invalid. {validationResult.Errors.ToList()}"); } private void ProcessConfiguration(RateLimiterConfiguration configuration) From c54927a9a415492463ad4e60414d9018e45a5c82 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 19 Feb 2025 07:56:54 -0500 Subject: [PATCH 27/29] housekeeping & fixed test --- RateLimiter.Tests/RateLimiterTest.cs | 1 + RateLimiter/RateLimiter.cs | 16 ++---- .../Rules/Algorithms/AlgorithmProvider.cs | 52 +++++++------------ submission.md | 43 ++++++++++++++- 4 files changed, 66 insertions(+), 46 deletions(-) diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 479b8afc..a0867b7e 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -89,6 +89,7 @@ public void IsRequestAllowed() mocker.Use(new DateTimeProvider()); mocker.Use(new DiscriminatorProvider(null, null)); + mocker.Use(new RateLimiterConfigurationValidator()); // mock the rule attribute as would be applied to our resource's endpoint var rateLimitedResources = new List() diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 6bfe80ad..d99a72c9 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -7,12 +6,11 @@ using RateLimiter.Config; using RateLimiter.Discriminators; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using static RateLimiter.Config.RateLimiterConfiguration; - namespace RateLimiter; public class RateLimiter : IRateLimiter @@ -21,12 +19,6 @@ public class RateLimiter : IRateLimiter private readonly IOptions _options; private readonly RateLimiterConfiguration _config; private readonly IRateLimitAlgorithmProvider _algorithmProvider; - - /// - /// List of rules as defined in appSettings.RateLimiter section (or via Fluent registration) - /// - private readonly IEnumerable _rules; - private readonly IRateLimitDiscriminatorProvider _discriminatorsProvider; private readonly IRateLimiterConfigurationValidator _configurationValidator; @@ -42,10 +34,8 @@ public RateLimiter( { _logger = logger; _algorithmProvider = algorithmProvider; - // TODO: IOptions should be replaced with IOptionsMonitor for hot-reloading - _options = options; + _options = options; // TODO: IOptions should be replaced with IOptionsMonitor for hot-reloading _config = options.Value; - _rules = options.Value.Rules; _discriminatorsProvider = discriminatorsProvider; _configurationValidator = configurationValidator; diff --git a/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs b/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs index 0b17fde3..ad308110 100644 --- a/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs +++ b/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs @@ -9,20 +9,8 @@ namespace RateLimiter.Rules.Algorithms { - public class AlgorithmProvider : IRateLimitAlgorithmProvider + public class AlgorithmProvider(IDateTimeProvider dateTimeProvider) : IRateLimitAlgorithmProvider { - private readonly IDateTimeProvider _dateTimeProvider; - private readonly IOptions _options; - - public AlgorithmProvider( - IDateTimeProvider dateTimeProvider, - IOptions options) - { - _dateTimeProvider = dateTimeProvider; - _options = options; - } - - public ConcurrentDictionary GenerateAlgorithmsFromRules(RateLimiterConfiguration configuration) { @@ -33,56 +21,56 @@ public ConcurrentDictionary switch (algo.Type) { case AlgorithmType.FixedWindow: - if (!algorithms.TryGetValue(algo.Name, out var existingAlgo)) + if (!algorithms.TryGetValue(algo.Name, out _)) { - algorithms.TryAdd(algo.Name, new FixedWindow(_dateTimeProvider, + algorithms.TryAdd(algo.Name, new FixedWindow(dateTimeProvider, new FixedWindowConfiguration() { - MaxRequests = algo.Parameters.MaxRequests.Value, - WindowDuration = TimeSpan.FromMilliseconds(algo.Parameters.WindowDurationMS.Value) + MaxRequests = algo.Parameters.MaxRequests!.Value, + WindowDuration = TimeSpan.FromMilliseconds(algo.Parameters.WindowDurationMS!.Value) })); } break; case AlgorithmType.LeakyBucket: - if (!algorithms.TryGetValue(algo.Name, out var existingLeaky)) + if (!algorithms.TryGetValue(algo.Name, out _)) { - algorithms.TryAdd(algo.Name, new LeakyBucket(_dateTimeProvider, + algorithms.TryAdd(algo.Name, new LeakyBucket(dateTimeProvider, new LeakyBucketConfiguration() { - Capacity = algo.Parameters.Capacity.Value, - Interval = TimeSpan.FromMilliseconds(algo.Parameters.IntervalMS.Value) + Capacity = algo.Parameters.Capacity!.Value, + Interval = TimeSpan.FromMilliseconds(algo.Parameters.IntervalMS!.Value) })); } break; case AlgorithmType.SlidingWindow: - if (!algorithms.TryGetValue(algo.Name, out var existingSliding)) + if (!algorithms.TryGetValue(algo.Name, out _)) { - algorithms.TryAdd(algo.Name, new SlidingWindow(_dateTimeProvider, + algorithms.TryAdd(algo.Name, new SlidingWindow(dateTimeProvider, new SlidingWindowConfiguration() { - MaxRequests = algo.Parameters.MaxRequests.Value, - WindowDuration = TimeSpan.FromMilliseconds(algo.Parameters.WindowDurationMS.Value) + MaxRequests = algo.Parameters.MaxRequests!.Value, + WindowDuration = TimeSpan.FromMilliseconds(algo.Parameters.WindowDurationMS!.Value) })); } break; case AlgorithmType.TimespanElapsed: - if (!algorithms.TryGetValue(algo.Name, out var existingTSElapsed)) + if (!algorithms.TryGetValue(algo.Name, out _)) { - algorithms.TryAdd(algo.Name, new TimespanElapsed(_dateTimeProvider, + algorithms.TryAdd(algo.Name, new TimespanElapsed(dateTimeProvider, new TimespanElapsedConfiguration() { - MinInterval = TimeSpan.FromMilliseconds(algo.Parameters.MinIntervalMS.Value) + MinInterval = TimeSpan.FromMilliseconds(algo.Parameters.MinIntervalMS!.Value) })); } break; case AlgorithmType.TokenBucket: - if (!algorithms.TryGetValue(algo.Name, out var existingTokenBucket)) + if (!algorithms.TryGetValue(algo.Name, out _)) { - algorithms.TryAdd(algo.Name, new TokenBucket(_dateTimeProvider, + algorithms.TryAdd(algo.Name, new TokenBucket(dateTimeProvider, new TokenBucketConfiguration() { - RefillRatePerSecond = algo.Parameters.RefillRatePerSecond.Value, - MaxTokens = algo.Parameters.MaxTokens.Value + RefillRatePerSecond = algo.Parameters.RefillRatePerSecond!.Value, + MaxTokens = algo.Parameters.MaxTokens!.Value })); } break; diff --git a/submission.md b/submission.md index e72badf3..c14d576b 100644 --- a/submission.md +++ b/submission.md @@ -242,4 +242,45 @@ Changing the library to use this approach would constitute changes to: - RateLimiter needs to check the discriminator's result to detect if a specific algorithm is demanded - Updating unit tests -Entire effort would take approx 4-6 hours. In a real-world scenario, I'd give myself 8 hours and assign 3 points to the work item. It could likely be pointed as a 2, but I'd suggest 3 as a safe bet. \ No newline at end of file +Entire effort would take approx 4-6 hours. In a real-world scenario, I'd give myself 8 hours and assign 3 points to the work item. It could likely be pointed as a 2, but I'd suggest 3 as a safe bet. +## Final Implementation (for this effort) +Based on the content in Epilogue, I reworked RateLimiter so that rules could operate with multiple algorithms. This approach allows us to define a single rule for our Geo-Based Token discriminator. If the discriminator detects a match, it returns a result definining which algorithm should be utilized. + +The new configuration looks like: +``` + "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" ] + } + ] + } +``` \ No newline at end of file From 0ce58f29e83e5b50a741940ebdb59a54b09708a3 Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 19 Feb 2025 07:58:55 -0500 Subject: [PATCH 28/29] ignore nullable due to config checks --- RateLimiter/RateLimiter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index d99a72c9..1bd98249 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -106,7 +106,7 @@ private void ProcessConfiguration(RateLimiterConfiguration configuration) foreach (var x in results.Where(r => r.IsMatch)) { lastRule = $"{x.DiscriminatorName}:{x.AlgorithmName}"; - passed = _algorithms[x.AlgorithmName].IsAllowed(x.MatchValue); + passed = _algorithms[x.AlgorithmName!].IsAllowed(x.MatchValue); if (!passed) break; } From 7d2f368848f9af7337598038373adde1859bd03b Mon Sep 17 00:00:00 2001 From: Randall Sexton Date: Wed, 19 Feb 2025 17:00:48 -0500 Subject: [PATCH 29/29] just moved some files to diff ns --- RateLimiter.Tests/RateLimiterTest.cs | 2 +- RateLimiter.Tests/Rules/FixedWindowRuleTests.cs | 4 +--- .../{Rules => }/Algorithms/AlgorithmProvider.cs | 3 +-- RateLimiter/{Rules => }/Algorithms/FixedWindow.cs | 3 +-- .../Algorithms/FixedWindowConfiguration.cs | 7 +++---- RateLimiter/{Rules => }/Algorithms/LeakyBucket.cs | 3 +-- .../Algorithms/LeakyBucketConfiguration.cs | 2 +- .../{Rules => }/Algorithms/SlidingWindow.cs | 2 +- .../Algorithms/SlidingWindowConfiguration.cs | 2 +- .../{Rules => }/Algorithms/TimespanElapsed.cs | 3 +-- .../Algorithms/TimespanElapsedConfiguration.cs | 2 +- RateLimiter/{Rules => }/Algorithms/TokenBucket.cs | 2 +- .../Algorithms/TokenBucketConfiguration.cs | 2 +- .../DependencyInjection/RateLimiterRegister.cs | 2 +- RateLimiter/Rules/RateLimitRule.cs | 15 --------------- 15 files changed, 16 insertions(+), 38 deletions(-) rename RateLimiter/{Rules => }/Algorithms/AlgorithmProvider.cs (98%) rename RateLimiter/{Rules => }/Algorithms/FixedWindow.cs (97%) rename RateLimiter/{Rules => }/Algorithms/FixedWindowConfiguration.cs (65%) rename RateLimiter/{Rules => }/Algorithms/LeakyBucket.cs (97%) rename RateLimiter/{Rules => }/Algorithms/LeakyBucketConfiguration.cs (85%) rename RateLimiter/{Rules => }/Algorithms/SlidingWindow.cs (97%) rename RateLimiter/{Rules => }/Algorithms/SlidingWindowConfiguration.cs (86%) rename RateLimiter/{Rules => }/Algorithms/TimespanElapsed.cs (96%) rename RateLimiter/{Rules => }/Algorithms/TimespanElapsedConfiguration.cs (73%) rename RateLimiter/{Rules => }/Algorithms/TokenBucket.cs (97%) rename RateLimiter/{Rules => }/Algorithms/TokenBucketConfiguration.cs (85%) delete mode 100644 RateLimiter/Rules/RateLimitRule.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index a0867b7e..c1eb0099 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -12,11 +12,11 @@ using Moq.AutoMock; using RateLimiter.Abstractions; +using RateLimiter.Algorithms; using RateLimiter.Common; using RateLimiter.Config; using RateLimiter.Discriminators; using RateLimiter.Enums; -using RateLimiter.Rules.Algorithms; using System.Collections.Generic; using System.Threading; diff --git a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs index 5cb2d0d4..7d2b5536 100644 --- a/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs +++ b/RateLimiter.Tests/Rules/FixedWindowRuleTests.cs @@ -1,9 +1,7 @@ using FluentAssertions; +using RateLimiter.Algorithms; using RateLimiter.Common; -using RateLimiter.Rules; -using RateLimiter.Rules.Algorithms; - using System; using System.Threading; diff --git a/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs b/RateLimiter/Algorithms/AlgorithmProvider.cs similarity index 98% rename from RateLimiter/Rules/Algorithms/AlgorithmProvider.cs rename to RateLimiter/Algorithms/AlgorithmProvider.cs index ad308110..63aa9ead 100644 --- a/RateLimiter/Rules/Algorithms/AlgorithmProvider.cs +++ b/RateLimiter/Algorithms/AlgorithmProvider.cs @@ -3,11 +3,10 @@ using RateLimiter.Abstractions; using RateLimiter.Config; using RateLimiter.Enums; - using System; using System.Collections.Concurrent; -namespace RateLimiter.Rules.Algorithms +namespace RateLimiter.Algorithms { public class AlgorithmProvider(IDateTimeProvider dateTimeProvider) : IRateLimitAlgorithmProvider { diff --git a/RateLimiter/Rules/Algorithms/FixedWindow.cs b/RateLimiter/Algorithms/FixedWindow.cs similarity index 97% rename from RateLimiter/Rules/Algorithms/FixedWindow.cs rename to RateLimiter/Algorithms/FixedWindow.cs index d19c1b26..097e8714 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindow.cs +++ b/RateLimiter/Algorithms/FixedWindow.cs @@ -1,10 +1,9 @@ using RateLimiter.Abstractions; using RateLimiter.Enums; - using System; using System.Collections.Concurrent; -namespace RateLimiter.Rules.Algorithms; +namespace RateLimiter.Algorithms; public class FixedWindow : IRateLimitAlgorithm { diff --git a/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs b/RateLimiter/Algorithms/FixedWindowConfiguration.cs similarity index 65% rename from RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs rename to RateLimiter/Algorithms/FixedWindowConfiguration.cs index 9ab45fb8..3a193bde 100644 --- a/RateLimiter/Rules/Algorithms/FixedWindowConfiguration.cs +++ b/RateLimiter/Algorithms/FixedWindowConfiguration.cs @@ -1,8 +1,7 @@ -using RateLimiter.Abstractions; +using System; +using RateLimiter.Abstractions; -using System; - -namespace RateLimiter.Rules.Algorithms; +namespace RateLimiter.Algorithms; public record FixedWindowConfiguration : IRateLimitAlgorithmConfiguration { diff --git a/RateLimiter/Rules/Algorithms/LeakyBucket.cs b/RateLimiter/Algorithms/LeakyBucket.cs similarity index 97% rename from RateLimiter/Rules/Algorithms/LeakyBucket.cs rename to RateLimiter/Algorithms/LeakyBucket.cs index 5f64b711..56d50f93 100644 --- a/RateLimiter/Rules/Algorithms/LeakyBucket.cs +++ b/RateLimiter/Algorithms/LeakyBucket.cs @@ -1,10 +1,9 @@ using RateLimiter.Abstractions; using RateLimiter.Enums; - using System; using System.Collections.Concurrent; -namespace RateLimiter.Rules.Algorithms; +namespace RateLimiter.Algorithms; public class LeakyBucket : IRateLimitAlgorithm { diff --git a/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs b/RateLimiter/Algorithms/LeakyBucketConfiguration.cs similarity index 85% rename from RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs rename to RateLimiter/Algorithms/LeakyBucketConfiguration.cs index 97e9bee7..5026ff63 100644 --- a/RateLimiter/Rules/Algorithms/LeakyBucketConfiguration.cs +++ b/RateLimiter/Algorithms/LeakyBucketConfiguration.cs @@ -2,7 +2,7 @@ using System; -namespace RateLimiter.Rules.Algorithms +namespace RateLimiter.Algorithms { public class LeakyBucketConfiguration : IRateLimitAlgorithmConfiguration { diff --git a/RateLimiter/Rules/Algorithms/SlidingWindow.cs b/RateLimiter/Algorithms/SlidingWindow.cs similarity index 97% rename from RateLimiter/Rules/Algorithms/SlidingWindow.cs rename to RateLimiter/Algorithms/SlidingWindow.cs index d5ffe1e5..99e46d84 100644 --- a/RateLimiter/Rules/Algorithms/SlidingWindow.cs +++ b/RateLimiter/Algorithms/SlidingWindow.cs @@ -5,7 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; -namespace RateLimiter.Rules.Algorithms; +namespace RateLimiter.Algorithms; public class SlidingWindow : IRateLimitAlgorithm { diff --git a/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs b/RateLimiter/Algorithms/SlidingWindowConfiguration.cs similarity index 86% rename from RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs rename to RateLimiter/Algorithms/SlidingWindowConfiguration.cs index bc03d6eb..70bbacca 100644 --- a/RateLimiter/Rules/Algorithms/SlidingWindowConfiguration.cs +++ b/RateLimiter/Algorithms/SlidingWindowConfiguration.cs @@ -2,7 +2,7 @@ using System; -namespace RateLimiter.Rules.Algorithms +namespace RateLimiter.Algorithms { public class SlidingWindowConfiguration : IRateLimitAlgorithmConfiguration { diff --git a/RateLimiter/Rules/Algorithms/TimespanElapsed.cs b/RateLimiter/Algorithms/TimespanElapsed.cs similarity index 96% rename from RateLimiter/Rules/Algorithms/TimespanElapsed.cs rename to RateLimiter/Algorithms/TimespanElapsed.cs index 346b2885..d32a576e 100644 --- a/RateLimiter/Rules/Algorithms/TimespanElapsed.cs +++ b/RateLimiter/Algorithms/TimespanElapsed.cs @@ -1,10 +1,9 @@ using RateLimiter.Abstractions; using RateLimiter.Enums; - using System; using System.Collections.Concurrent; -namespace RateLimiter.Rules.Algorithms +namespace RateLimiter.Algorithms { public class TimespanElapsed : IRateLimitAlgorithm { diff --git a/RateLimiter/Rules/Algorithms/TimespanElapsedConfiguration.cs b/RateLimiter/Algorithms/TimespanElapsedConfiguration.cs similarity index 73% rename from RateLimiter/Rules/Algorithms/TimespanElapsedConfiguration.cs rename to RateLimiter/Algorithms/TimespanElapsedConfiguration.cs index d37348b4..aa93ad48 100644 --- a/RateLimiter/Rules/Algorithms/TimespanElapsedConfiguration.cs +++ b/RateLimiter/Algorithms/TimespanElapsedConfiguration.cs @@ -1,6 +1,6 @@ using System; -namespace RateLimiter.Rules.Algorithms; +namespace RateLimiter.Algorithms; public class TimespanElapsedConfiguration { diff --git a/RateLimiter/Rules/Algorithms/TokenBucket.cs b/RateLimiter/Algorithms/TokenBucket.cs similarity index 97% rename from RateLimiter/Rules/Algorithms/TokenBucket.cs rename to RateLimiter/Algorithms/TokenBucket.cs index be55c71e..867d566e 100644 --- a/RateLimiter/Rules/Algorithms/TokenBucket.cs +++ b/RateLimiter/Algorithms/TokenBucket.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Concurrent; -namespace RateLimiter.Rules.Algorithms; +namespace RateLimiter.Algorithms; public class TokenBucket : IRateLimitAlgorithm { diff --git a/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs b/RateLimiter/Algorithms/TokenBucketConfiguration.cs similarity index 85% rename from RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs rename to RateLimiter/Algorithms/TokenBucketConfiguration.cs index 9ff4e111..054926eb 100644 --- a/RateLimiter/Rules/Algorithms/TokenBucketConfiguration.cs +++ b/RateLimiter/Algorithms/TokenBucketConfiguration.cs @@ -1,6 +1,6 @@ using RateLimiter.Abstractions; -namespace RateLimiter.Rules.Algorithms +namespace RateLimiter.Algorithms { public class TokenBucketConfiguration : IRateLimitAlgorithmConfiguration { diff --git a/RateLimiter/DependencyInjection/RateLimiterRegister.cs b/RateLimiter/DependencyInjection/RateLimiterRegister.cs index cabe1b9b..5f657c4f 100644 --- a/RateLimiter/DependencyInjection/RateLimiterRegister.cs +++ b/RateLimiter/DependencyInjection/RateLimiterRegister.cs @@ -3,11 +3,11 @@ using Microsoft.Extensions.DependencyInjection; using RateLimiter.Abstractions; +using RateLimiter.Algorithms; using RateLimiter.Common; using RateLimiter.Config; using RateLimiter.Discriminators; using RateLimiter.Middleware; -using RateLimiter.Rules.Algorithms; namespace RateLimiter.DependencyInjection; diff --git a/RateLimiter/Rules/RateLimitRule.cs b/RateLimiter/Rules/RateLimitRule.cs deleted file mode 100644 index 5e82dfa9..00000000 --- a/RateLimiter/Rules/RateLimitRule.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace RateLimiter.Rules -{ - public class RateLimitRule - { - public string Name { get; set; } - - - } -}