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/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; + } + } + +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..83d4eba3 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -1,15 +1,28 @@ - - - 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.sln b/RateLimiter.sln index 626a7bfa..53f7c270 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 +MinimumVisualStudioVersion = 10.0.40219.1 +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 +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 + {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 + 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/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/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 new file mode 100644 index 00000000..03916220 --- /dev/null +++ b/RateLimiter/Program.cs @@ -0,0 +1,34 @@ +using RateLimiter.Middleware; +using RateLimiter.Services; + +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(); + +builder.Services.AddMemoryCache(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.UseApiKeyMiddleware(); +app.UseRateLimiterMiddleware(); + +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/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/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; + } + } +} 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 + } +} 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..e4fa9946 --- /dev/null +++ b/RateLimiter/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "RateLimiting": { + "CacheExpirationMinutes": 5, + "FixedWindow": { + "MaxRequests": 5, + "UnitOfTime": "Minute" + }, + "SlidingWindow": { + "WindowLengthSeconds": 30, + "MaxRequests": 3 + } + } +}