From 541b16b358fc5914a7fd0bf030732b4b8169bf1f Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 14:20:11 -0800 Subject: [PATCH 1/8] Outline the WeatherForecast API --- RateLimiter.Tests/RateLimiter.Tests.csproj | 3 - RateLimiter.sln | 72 +++++++++---------- .../Controllers/WeatherForecastController.cs | 33 +++++++++ RateLimiter/Program.cs | 25 +++++++ RateLimiter/Properties/launchSettings.json | 41 +++++++++++ RateLimiter/RateLimiter.csproj | 20 ++++-- RateLimiter/RateLimiter.csproj.user | 6 ++ RateLimiter/RateLimiter.http | 6 ++ RateLimiter/WeatherForecast.cs | 13 ++++ RateLimiter/appsettings.Development.json | 8 +++ RateLimiter/appsettings.json | 9 +++ 11 files changed, 190 insertions(+), 46 deletions(-) create mode 100644 RateLimiter/Controllers/WeatherForecastController.cs create mode 100644 RateLimiter/Program.cs create mode 100644 RateLimiter/Properties/launchSettings.json create mode 100644 RateLimiter/RateLimiter.csproj.user create mode 100644 RateLimiter/RateLimiter.http create mode 100644 RateLimiter/WeatherForecast.cs create mode 100644 RateLimiter/appsettings.Development.json create mode 100644 RateLimiter/appsettings.json diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..8b6b8696 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -4,9 +4,6 @@ latest enable - - - diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..98b8873d 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,36 +1,36 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.15 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}" - ProjectSection(SolutionItems) = preProject - README.md = README.md - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.Build.0 = Release|Any CPU - {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {67D05CB6-8603-4C96-97E5-C6CEFBEC6134} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35514.174 d17.12 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{96053C2C-25E5-4DC1-80B8-B6B4102C5585}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {67D05CB6-8603-4C96-97E5-C6CEFBEC6134} + EndGlobalSection +EndGlobal diff --git a/RateLimiter/Controllers/WeatherForecastController.cs b/RateLimiter/Controllers/WeatherForecastController.cs new file mode 100644 index 00000000..96dc135d --- /dev/null +++ b/RateLimiter/Controllers/WeatherForecastController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; + +namespace RateLimiter.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; + } + + [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/Program.cs b/RateLimiter/Program.cs new file mode 100644 index 00000000..15eacee9 --- /dev/null +++ b/RateLimiter/Program.cs @@ -0,0 +1,25 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/RateLimiter/Properties/launchSettings.json b/RateLimiter/Properties/launchSettings.json new file mode 100644 index 00000000..1728de95 --- /dev/null +++ b/RateLimiter/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:20127", + "sslPort": 44374 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5039", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7121;http://localhost:5039", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..fa018d27 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -1,7 +1,13 @@ - - - net6.0 - latest - enable - - \ No newline at end of file + + + + net8.0 + enable + enable + + + + + + + diff --git a/RateLimiter/RateLimiter.csproj.user b/RateLimiter/RateLimiter.csproj.user new file mode 100644 index 00000000..ccfffb1f --- /dev/null +++ b/RateLimiter/RateLimiter.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/RateLimiter/RateLimiter.http b/RateLimiter/RateLimiter.http new file mode 100644 index 00000000..b5303a73 --- /dev/null +++ b/RateLimiter/RateLimiter.http @@ -0,0 +1,6 @@ +@RateLimiter_HostAddress = http://localhost:5039 + +GET {{RateLimiter_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/RateLimiter/WeatherForecast.cs b/RateLimiter/WeatherForecast.cs new file mode 100644 index 00000000..2e4c9624 --- /dev/null +++ b/RateLimiter/WeatherForecast.cs @@ -0,0 +1,13 @@ +namespace RateLimiter +{ + 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/appsettings.Development.json b/RateLimiter/appsettings.Development.json new file mode 100644 index 00000000..ff66ba6b --- /dev/null +++ b/RateLimiter/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/RateLimiter/appsettings.json b/RateLimiter/appsettings.json new file mode 100644 index 00000000..4d566948 --- /dev/null +++ b/RateLimiter/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 50856bd717cb41ed0c9569426c747931560fc604 Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 14:30:28 -0800 Subject: [PATCH 2/8] Add API key middleware --- RateLimiter/Middleware/ApiKeyMiddleware.cs | 75 +++++++++++++++++++ RateLimiter/Program.cs | 4 + RateLimiter/Services/RateLimiterPolicyEnum.cs | 8 ++ 3 files changed, 87 insertions(+) create mode 100644 RateLimiter/Middleware/ApiKeyMiddleware.cs create mode 100644 RateLimiter/Services/RateLimiterPolicyEnum.cs diff --git a/RateLimiter/Middleware/ApiKeyMiddleware.cs b/RateLimiter/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 00000000..7c432305 --- /dev/null +++ b/RateLimiter/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,75 @@ +using RateLimiter.Services; + +namespace RateLimiter.Middleware +{ + public class ApiKeyMiddleware(RequestDelegate next) + { + /* + In a real app this would interact with an actual authorization service to validate the API key on the request. + Hardcoding some API keys here for convenience. + You can use these API keys to interact with the rate limiter. + For example: + curl -k -X 'GET' 'https://localhost:7219/WeatherForecast' -H 'accept: text/plain' -H 'x-api-key: fd546133b56ccdd65a31b86a2b88dd9c' + */ + + private readonly HashSet _europeanApiKeys = + [ + "fd546133b56ccdd65a31b86a2b88dd9c", + "49d3ad978e01a8fd57849bd56b66ae8d", + "49705cbb56ee596e03765cf7815f9858" + ]; + + private readonly HashSet _northAmericanApiKeys = + [ + "fd546133b56ccdd65a31b86a2b88dd9c", + "476093bdee0180ac729115221a45a2a1", + "981729af12bac6b5e8e982aae064b96a" + ]; + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Headers.TryGetValue("x-api-key", out var apiKeyValue) || string.IsNullOrWhiteSpace(apiKeyValue)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsync("API key is missing"); + return; + } + + string apiKey = apiKeyValue.ToString(); + + bool IsRegionMember(string apiKey, HashSet regionApiKeys) => + regionApiKeys.Contains(apiKey); + + bool IsUnknownApiKey(string apiKey, IEnumerable> apiKeyCollections) => + !apiKeyCollections.Any(collection => IsRegionMember(apiKey, collection)); + + if (IsUnknownApiKey(apiKey, [_europeanApiKeys, _northAmericanApiKeys])) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync("Invalid API key"); + return; + } + + var applicablePolicies = new HashSet(); + context.Items["ApplicablePolicies"] = applicablePolicies; + + if (IsRegionMember(apiKey, _europeanApiKeys)) + { + applicablePolicies.Add(RateLimiterPolicyEnum.European); + } + + if (IsRegionMember(apiKey, _northAmericanApiKeys)) + { + applicablePolicies.Add(RateLimiterPolicyEnum.NorthAmerican); + } + + await next(context); + } + } + + public static class ApiKeyMiddlewareExtensions + { + public static IApplicationBuilder UseApiKeyMiddleware(this IApplicationBuilder builder) => + builder.UseMiddleware(); + } +} diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs index 15eacee9..c66bde17 100644 --- a/RateLimiter/Program.cs +++ b/RateLimiter/Program.cs @@ -1,3 +1,5 @@ +using RateLimiter.Middleware; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. @@ -22,4 +24,6 @@ app.MapControllers(); +app.UseApiKeyMiddleware(); + app.Run(); diff --git a/RateLimiter/Services/RateLimiterPolicyEnum.cs b/RateLimiter/Services/RateLimiterPolicyEnum.cs new file mode 100644 index 00000000..69ea17a4 --- /dev/null +++ b/RateLimiter/Services/RateLimiterPolicyEnum.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Services +{ + enum RateLimiterPolicyEnum + { + European, + NorthAmerican + } +} From 4f84eec7e54a04b18b9e33bbade2e9f7234fb6d6 Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 14:35:21 -0800 Subject: [PATCH 3/8] Add in-memory cache and classes for tracking client activity --- RateLimiter/Program.cs | 4 ++ RateLimiter/Services/ClientBehaviorCache.cs | 56 +++++++++++++++++++++ RateLimiter/appsettings.json | 13 ++++- 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 RateLimiter/Services/ClientBehaviorCache.cs diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs index c66bde17..1c6561ba 100644 --- a/RateLimiter/Program.cs +++ b/RateLimiter/Program.cs @@ -1,4 +1,5 @@ using RateLimiter.Middleware; +using RateLimiter.Services; var builder = WebApplication.CreateBuilder(args); @@ -9,6 +10,9 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddMemoryCache(); +builder.Services.AddSingleton(); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/RateLimiter/Services/ClientBehaviorCache.cs b/RateLimiter/Services/ClientBehaviorCache.cs new file mode 100644 index 00000000..bf5a923c --- /dev/null +++ b/RateLimiter/Services/ClientBehaviorCache.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Caching.Memory; +using System.Collections.Concurrent; + +namespace RateLimiter.Services +{ + public class ClientBehaviorCache(IMemoryCache memoryCache, IConfiguration configuration) + { + public void Add(string apiKey, DateTime requestTime) + { + // Record the request time if the cache contains an entry for this API key. + bool hasEntry = memoryCache.TryGetValue(apiKey, out var priorActivities); + if (hasEntry) + { + if (priorActivities is ClientActivityTracker clientTracker) + { + clientTracker.RecordActivity(requestTime); + return; + } + } + + // Otherwise use the factory method to configure entry expiration. + memoryCache.GetOrCreate(apiKey, entry => + { + int expirationWindow = Convert.ToInt32(configuration["RateLimiting:CacheExpirationMinutes"]); + entry.SlidingExpiration = TimeSpan.FromMinutes(expirationWindow); + + return new ClientActivityTracker(requestTime); + }); + } + + public ClientActivityTracker Get(string apiKey) + { + if (memoryCache.TryGetValue(apiKey, out var priorActivities)) + { + if (priorActivities is ClientActivityTracker clientTracker) + { + return clientTracker; + } + } + + string errorMessage = "Logic Error: The API key should always be present and refer to a non-null value when this is called."; + throw new InvalidOperationException(errorMessage); + } + + public class ClientActivityTracker : ConcurrentQueue + { + public ClientActivityTracker(DateTime timeStamp) => + RecordActivity(timeStamp); + + public void RecordActivity(DateTime timeStamp) + { + Enqueue(timeStamp); + } + } + } +} diff --git a/RateLimiter/appsettings.json b/RateLimiter/appsettings.json index 4d566948..e4fa9946 100644 --- a/RateLimiter/appsettings.json +++ b/RateLimiter/appsettings.json @@ -5,5 +5,16 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "RateLimiting": { + "CacheExpirationMinutes": 5, + "FixedWindow": { + "MaxRequests": 5, + "UnitOfTime": "Minute" + }, + "SlidingWindow": { + "WindowLengthSeconds": 30, + "MaxRequests": 3 + } + } } From 4d20c759d3584cbc5918c62dc288e0a252d30cc3 Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 14:39:27 -0800 Subject: [PATCH 4/8] Add rate limiter policy interface and concrete classes --- .../Services/EuropeanRateLimiterPolicy.cs | 27 +++++++++++++++++++ RateLimiter/Services/IRateLimiterPolicy.cs | 7 +++++ .../NorthAmericanRateLimiterPolicy.cs | 19 +++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 RateLimiter/Services/EuropeanRateLimiterPolicy.cs create mode 100644 RateLimiter/Services/IRateLimiterPolicy.cs create mode 100644 RateLimiter/Services/NorthAmericanRateLimiterPolicy.cs diff --git a/RateLimiter/Services/EuropeanRateLimiterPolicy.cs b/RateLimiter/Services/EuropeanRateLimiterPolicy.cs new file mode 100644 index 00000000..47e7f00f --- /dev/null +++ b/RateLimiter/Services/EuropeanRateLimiterPolicy.cs @@ -0,0 +1,27 @@ +namespace RateLimiter.Services +{ + public class EuropeanRateLimiterPolicy(ClientBehaviorCache clientBehaviorCache, IConfiguration configuration) : IRateLimiterPolicy + { + public bool IsApplicable(string apiKey, DateTime requestTime) => IsRateLimitedByFixedWindow(apiKey, requestTime); + + private bool IsRateLimitedByFixedWindow(string apiKey, DateTime requestTime) + { + int maxRequests = Convert.ToInt32(configuration["RateLimiting:FixedWindow:MaxRequests"]); + string fixedWindowUnit = configuration["RateLimiting:FixedWindow:UnitOfTime"]!; + + Func fixedWindowPredicate = fixedWindowUnit switch + { + "Second" => r => r.Minute == requestTime.Second, + "Minute" => r => r.Minute == requestTime.Minute, + "Hour" => r => r.Minute == requestTime.Hour, + _ => throw new ArgumentException($"Invalid unit: {fixedWindowUnit}") + }; + + var requestsInWindow = clientBehaviorCache + .Get(apiKey) + .Where(fixedWindowPredicate); + + return requestsInWindow.Count() > maxRequests; + } + } +} diff --git a/RateLimiter/Services/IRateLimiterPolicy.cs b/RateLimiter/Services/IRateLimiterPolicy.cs new file mode 100644 index 00000000..5b10f54f --- /dev/null +++ b/RateLimiter/Services/IRateLimiterPolicy.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Services +{ + public interface IRateLimiterPolicy + { + public bool IsApplicable(string apiKey, DateTime requestTime); + } +} diff --git a/RateLimiter/Services/NorthAmericanRateLimiterPolicy.cs b/RateLimiter/Services/NorthAmericanRateLimiterPolicy.cs new file mode 100644 index 00000000..08534b52 --- /dev/null +++ b/RateLimiter/Services/NorthAmericanRateLimiterPolicy.cs @@ -0,0 +1,19 @@ +namespace RateLimiter.Services +{ + public class NorthAmericanRateLimiterPolicy(ClientBehaviorCache clientBehaviorCache, IConfiguration configuration) : IRateLimiterPolicy + { + public bool IsApplicable(string apiKey, DateTime requestTime) => IsRateLimitedBySlidingWindow(apiKey, requestTime); + + private bool IsRateLimitedBySlidingWindow(string apiKey, DateTime requestTime) + { + int maxRequests = Convert.ToInt32(configuration["RateLimiting:SlidingWindow:MaxRequests"]); + int slidingWindowLengthSeconds = Convert.ToInt32(configuration["RateLimiting:SlidingWindow:WindowLengthSeconds"]); + + var requestsInWindow = clientBehaviorCache + .Get(apiKey) + .Where(r => r.AddSeconds(slidingWindowLengthSeconds) >= requestTime); + + return requestsInWindow.Count() > maxRequests; + } + } +} From 29c79446fe511e30b21ba6fd271ec91a5f53c285 Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 14:44:49 -0800 Subject: [PATCH 5/8] Add middleware to evaluate requests against corresponding rate limiter policies --- .../Middleware/RateLimiterMiddleware.cs | 52 +++++++++++++++++++ RateLimiter/Program.cs | 1 + 2 files changed, 53 insertions(+) create mode 100644 RateLimiter/Middleware/RateLimiterMiddleware.cs diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs new file mode 100644 index 00000000..a8a05ae6 --- /dev/null +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -0,0 +1,52 @@ +using RateLimiter.Services; + +namespace RateLimiter.Middleware +{ + public class RateLimiterMiddleware(RequestDelegate next, ClientBehaviorCache clientBehaviorCache, IConfiguration configuration) + { + public async Task InvokeAsync(HttpContext context) + { + context.Request.Headers.TryGetValue("x-api-key", out var apiKey); + + var requestTime = DateTime.UtcNow; + + // Non-null api key and applicable policy instances guaranteed by API Key middleware. + clientBehaviorCache.Add(apiKey!, requestTime); + var applicablePolicies = context.Items["ApplicablePolicies"] as HashSet; + bool isEuropean = applicablePolicies!.Contains(RateLimiterPolicyEnum.European); + bool isNorthAmerican = applicablePolicies!.Contains(RateLimiterPolicyEnum.NorthAmerican); + + if (isEuropean) + { + var europeanPolicy = new EuropeanRateLimiterPolicy(clientBehaviorCache, configuration); + + if (europeanPolicy.IsApplicable(apiKey!, requestTime)) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsync("Too many requests per European policy"); + return; + } + } + + if (isNorthAmerican) + { + var northAmericanPolicy = new NorthAmericanRateLimiterPolicy(clientBehaviorCache, configuration); + + if (northAmericanPolicy.IsApplicable(apiKey!, requestTime)) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsync("Too many requests per North American policy"); + return; + } + } + + await next(context); + } + } + + public static class RateLimiterMiddlewareExtensions + { + public static IApplicationBuilder UseRateLimiterMiddleware(this IApplicationBuilder builder) => + builder.UseMiddleware(); + } +} diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs index 1c6561ba..03916220 100644 --- a/RateLimiter/Program.cs +++ b/RateLimiter/Program.cs @@ -29,5 +29,6 @@ app.MapControllers(); app.UseApiKeyMiddleware(); +app.UseRateLimiterMiddleware(); app.Run(); From 7220d304fc25db4ae525617f4ba30703c10101c1 Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 15:00:33 -0800 Subject: [PATCH 6/8] Opt for xUnit test project --- RateLimiter.Tests/RateLimiter.Tests.csproj | 35 ++++++++++++++-------- RateLimiter.Tests/RateLimiterTest.cs | 13 -------- RateLimiter.Tests/UnitTest1.cs | 11 +++++++ 3 files changed, 34 insertions(+), 25 deletions(-) delete mode 100644 RateLimiter.Tests/RateLimiterTest.cs create mode 100644 RateLimiter.Tests/UnitTest1.cs diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 8b6b8696..3aa98609 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -1,12 +1,23 @@ - - - net6.0 - latest - enable - - - - - - - \ No newline at end of file + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs deleted file mode 100644 index 172d44a7..00000000 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file diff --git a/RateLimiter.Tests/UnitTest1.cs b/RateLimiter.Tests/UnitTest1.cs new file mode 100644 index 00000000..421439b5 --- /dev/null +++ b/RateLimiter.Tests/UnitTest1.cs @@ -0,0 +1,11 @@ +namespace RateLimiter.Tests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} \ No newline at end of file From d705ff7d534484da4f4b4434c2bc87c1f4dcdbd7 Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 15:09:17 -0800 Subject: [PATCH 7/8] Add unit tests for European policy rate limiter --- .../EuropeanRateLimiterPolicyTests.cs | 96 +++++++++++++++++++ RateLimiter.Tests/RateLimiter.Tests.csproj | 5 + RateLimiter.Tests/UnitTest1.cs | 11 --- RateLimiter.sln | 14 +-- 4 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 RateLimiter.Tests/EuropeanRateLimiterPolicyTests.cs delete mode 100644 RateLimiter.Tests/UnitTest1.cs diff --git a/RateLimiter.Tests/EuropeanRateLimiterPolicyTests.cs b/RateLimiter.Tests/EuropeanRateLimiterPolicyTests.cs new file mode 100644 index 00000000..5497adf1 --- /dev/null +++ b/RateLimiter.Tests/EuropeanRateLimiterPolicyTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Moq; +using RateLimiter.Services; + +namespace RateLimiter.Tests +{ + public class EuropeanRateLimiterPolicyTests + { + [Fact] + public void TestRateLimitingNotAppliedBelowOrEqualToMaxRequests() + { + const int maxAllowedRequestsPerFixedWindow = 2; + const string unitOfTime = "Minute"; + const int cacheExpirationMinutes = 5; + var mockConfig = BuildTestConfig(maxAllowedRequestsPerFixedWindow, unitOfTime, cacheExpirationMinutes); + + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var clientBehaviorCache = new ClientBehaviorCache(memoryCache, mockConfig.Object); + var policy = new EuropeanRateLimiterPolicy(clientBehaviorCache, mockConfig.Object); + + string testApiKey = "test-api-key"; + var initialRequestTime = new DateTime(2024, 12, 9, 10, 10, 0); + string noRateLimitingExpectedMessage = $"Rate limiting should not apply until exceeding {maxAllowedRequestsPerFixedWindow} requests per period"; + + clientBehaviorCache.Add(testApiKey, initialRequestTime); + Assert.False(policy.IsApplicable(testApiKey, initialRequestTime), noRateLimitingExpectedMessage); + + var secondRequestTime = initialRequestTime.AddSeconds(58); + clientBehaviorCache.Add(testApiKey, secondRequestTime); + Assert.False(policy.IsApplicable(testApiKey, secondRequestTime), noRateLimitingExpectedMessage); + } + + [Fact] + public void TestRateLimitingAppliedWhenMaxRequestsExceeded() + { + const int maxAllowedRequestsPerFixedWindow = 2; + const string unitOfTime = "Minute"; + const int cacheExpirationMinutes = 5; + var mockConfig = BuildTestConfig(maxAllowedRequestsPerFixedWindow, unitOfTime, cacheExpirationMinutes); + + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var clientBehaviorCache = new ClientBehaviorCache(memoryCache, mockConfig.Object); + var policy = new EuropeanRateLimiterPolicy(clientBehaviorCache, mockConfig.Object); + + string testApiKey = "test-api-key"; + var initialRequestTime = new DateTime(2024, 12, 9, 10, 10, 0); + var secondRequestTime = initialRequestTime.AddSeconds(58); + var thirdRequestTime = initialRequestTime.AddSeconds(59); + + clientBehaviorCache.Add(testApiKey, initialRequestTime); + clientBehaviorCache.Add(testApiKey, secondRequestTime); + clientBehaviorCache.Add(testApiKey, thirdRequestTime); + + string rateLimitingExpectedMessage = $"Rate limiting applies after exceeding {maxAllowedRequestsPerFixedWindow} requests per period"; + Assert.True(policy.IsApplicable(testApiKey, thirdRequestTime), rateLimitingExpectedMessage); + } + + [Fact] + public void TestCanExceedRateLimitByAllocatingRequestsAcrossDistinctFixedWindows() + { + const int maxAllowedRequestsPerFixedWindow = 2; + const string unitOfTime = "Minute"; + const int cacheExpirationMinutes = 5; + var mockConfig = BuildTestConfig(maxAllowedRequestsPerFixedWindow, unitOfTime, cacheExpirationMinutes); + + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var clientBehaviorCache = new ClientBehaviorCache(memoryCache, mockConfig.Object); + var policy = new EuropeanRateLimiterPolicy(clientBehaviorCache, mockConfig.Object); + + string testApiKey = "test-api-key"; + var firstRequestTime = new DateTime(2024, 12, 9, 10, 10, 58); + var secondRequestTime = new DateTime(2024, 12, 9, 10, 10, 59); + var thirdRequestTime = new DateTime(2024, 12, 9, 10, 11, 1); + var finalRequestTime = new DateTime(2024, 12, 9, 10, 11, 2); + + clientBehaviorCache.Add(testApiKey, firstRequestTime); + clientBehaviorCache.Add(testApiKey, secondRequestTime); + clientBehaviorCache.Add(testApiKey, thirdRequestTime); + clientBehaviorCache.Add(testApiKey, finalRequestTime); + + string msg = "Should be able to exceed fixed window rate limit by allocating requests at end of window and at start of the next."; + Assert.False(policy.IsApplicable(testApiKey, finalRequestTime), msg); + } + + private static Mock BuildTestConfig(int maxAllowedRequestsPerFixedWindow, string unitOfTime, int cacheExpirationMinutes) + { + var mockConfig = new Mock(); + mockConfig.Setup(c => c["RateLimiting:FixedWindow:MaxRequests"]).Returns(maxAllowedRequestsPerFixedWindow.ToString()); + mockConfig.Setup(c => c["RateLimiting:FixedWindow:UnitOfTime"]).Returns(unitOfTime); + mockConfig.Setup(c => c["RateLimiting:CacheExpirationMinutes"]).Returns(cacheExpirationMinutes.ToString()); + + return mockConfig; + } + } +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 3aa98609..83d4eba3 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -12,10 +12,15 @@ + + + + + diff --git a/RateLimiter.Tests/UnitTest1.cs b/RateLimiter.Tests/UnitTest1.cs deleted file mode 100644 index 421439b5..00000000 --- a/RateLimiter.Tests/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace RateLimiter.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} \ No newline at end of file diff --git a/RateLimiter.sln b/RateLimiter.sln index 98b8873d..53f7c270 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,10 +1,8 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.12.35514.174 d17.12 +VisualStudioVersion = 17.12.35514.174 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}" ProjectSection(SolutionItems) = preProject README.md = README.md @@ -12,20 +10,22 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{96053C2C-25E5-4DC1-80B8-B6B4102C5585}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{E66B10CF-321E-4AFF-AD4B-EF3BF9D15D7D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Debug|Any CPU.Build.0 = Debug|Any CPU {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Release|Any CPU.ActiveCfg = Release|Any CPU {96053C2C-25E5-4DC1-80B8-B6B4102C5585}.Release|Any CPU.Build.0 = Release|Any CPU + {E66B10CF-321E-4AFF-AD4B-EF3BF9D15D7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E66B10CF-321E-4AFF-AD4B-EF3BF9D15D7D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E66B10CF-321E-4AFF-AD4B-EF3BF9D15D7D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E66B10CF-321E-4AFF-AD4B-EF3BF9D15D7D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 9cd4f9c389b80b46ce8ffa28b25cd0d2e4526230 Mon Sep 17 00:00:00 2001 From: Mike Griffin Date: Tue, 10 Dec 2024 15:11:38 -0800 Subject: [PATCH 8/8] Add unit tests for North American policy rate limiter --- .../NorthAmericanRateLimiterPolicyTests.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 RateLimiter.Tests/NorthAmericanRateLimiterPolicyTests.cs diff --git a/RateLimiter.Tests/NorthAmericanRateLimiterPolicyTests.cs b/RateLimiter.Tests/NorthAmericanRateLimiterPolicyTests.cs new file mode 100644 index 00000000..b84dd337 --- /dev/null +++ b/RateLimiter.Tests/NorthAmericanRateLimiterPolicyTests.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Moq; +using RateLimiter.Services; + +namespace RateLimiter.Tests +{ + public class NorthAmericanRateLimiterPolicyTests + { + [Fact] + public void TestRateLimitingNotAppliedBelowOrEqualToMaxRequests() + { + const int maxAllowedRequestsPerSlidingWindow = 3; + const int windowLengthSeconds = 30; + const int cacheExpirationMinutes = 5; + var mockConfig = BuildTestConfig(maxAllowedRequestsPerSlidingWindow, windowLengthSeconds, cacheExpirationMinutes); + + string testApiKey = "test-api-key"; + var firstRequestTime = new DateTime(2024, 12, 9, 10, 10, 0); + var secondRequestTime = firstRequestTime.AddSeconds(1); + var thirdRequestTime = firstRequestTime.AddSeconds(2); + + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var clientBehaviorCache = new ClientBehaviorCache(memoryCache, mockConfig.Object); + var policy = new NorthAmericanRateLimiterPolicy(clientBehaviorCache, mockConfig.Object); + + clientBehaviorCache.Add(testApiKey, firstRequestTime); + clientBehaviorCache.Add(testApiKey, secondRequestTime); + clientBehaviorCache.Add(testApiKey, thirdRequestTime); + + string msg = "No rate limiting expected if number of requests equal to max allowed"; + Assert.False(policy.IsApplicable(testApiKey, thirdRequestTime), msg); + } + + [Fact] + public void TestRateLimitingAppliedWhenMaxRequestsExceeded() + { + const int maxAllowedRequestsPerSlidingWindow = 3; + const int windowLengthSeconds = 30; + const int cacheExpirationMinutes = 5; + var mockConfig = BuildTestConfig(maxAllowedRequestsPerSlidingWindow, windowLengthSeconds, cacheExpirationMinutes); + + string testApiKey = "test-api-key"; + var firstRequestTime = new DateTime(2024, 12, 9, 10, 10, 0); + var secondRequestTime = firstRequestTime.AddSeconds(1); + var thirdRequestTime = firstRequestTime.AddSeconds(28); + var fourthRequestTime = firstRequestTime.AddSeconds(windowLengthSeconds); + + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var clientBehaviorCache = new ClientBehaviorCache(memoryCache, mockConfig.Object); + var policy = new NorthAmericanRateLimiterPolicy(clientBehaviorCache, mockConfig.Object); + + clientBehaviorCache.Add(testApiKey, firstRequestTime); + clientBehaviorCache.Add(testApiKey, secondRequestTime); + clientBehaviorCache.Add(testApiKey, thirdRequestTime); + clientBehaviorCache.Add(testApiKey, fourthRequestTime); + + string msg = "Rate limiting is expected when request count exceeds maximum allowed per period"; + Assert.True(policy.IsApplicable(testApiKey, thirdRequestTime), msg); + } + + [Fact] + public void TestRateLimiterDoesNotApplyWhenRequestsAreDistributedOverTime() + { + const int maxAllowedRequestsPerSlidingWindow = 3; + const int windowLengthSeconds = 30; + const int cacheExpirationMinutes = 5; + var mockConfig = BuildTestConfig(maxAllowedRequestsPerSlidingWindow, windowLengthSeconds, cacheExpirationMinutes); + + string testApiKey = "test-api-key"; + var firstRequestTime = new DateTime(2024, 12, 9, 10, 10, 0); + var secondRequestTime = firstRequestTime.AddSeconds(1); + var thirdRequestTime = firstRequestTime.AddSeconds(2); + + // This request is more than 30 seconds removed from initial request. No rate limiting expected. + var fourthRequestTime = firstRequestTime.AddSeconds(windowLengthSeconds + 1); + + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var clientBehaviorCache = new ClientBehaviorCache(memoryCache, mockConfig.Object); + var policy = new NorthAmericanRateLimiterPolicy(clientBehaviorCache, mockConfig.Object); + + clientBehaviorCache.Add(testApiKey, firstRequestTime); + clientBehaviorCache.Add(testApiKey, secondRequestTime); + clientBehaviorCache.Add(testApiKey, thirdRequestTime); + clientBehaviorCache.Add(testApiKey, fourthRequestTime); + + string msg = "Rate limiter should allow many requests as long as they don't fall into same sliding window"; + Assert.False(policy.IsApplicable(testApiKey, fourthRequestTime), msg); + } + + private static Mock BuildTestConfig(int maxAllowedRequestsPerSlidingWindow, int windowLengthSeconds, int cacheExpirationMinutes) + { + var mockConfig = new Mock(); + mockConfig.Setup(c => c["RateLimiting:SlidingWindow:MaxRequests"]).Returns(maxAllowedRequestsPerSlidingWindow.ToString()); + mockConfig.Setup(c => c["RateLimiting:SlidingWindow:WindowLengthSeconds"]).Returns(windowLengthSeconds.ToString()); + mockConfig.Setup(c => c["RateLimiting:CacheExpirationMinutes"]).Returns(cacheExpirationMinutes.ToString()); + + return mockConfig; + } + } + +}