diff --git a/README.md b/README.md index 47e73daa..ed98dfe7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,58 @@ -**Rate-limiting pattern** +## Usage + +### Register and configure Rate limiting services(rules) + +_Example:_ + +```csharp +builder.Services. + AddFixedWindow(configure => + { + configure.RuleConditions = new List> + { + token => token.Region == Region.us, + token => !string.IsNullOrEmpty(token.UserId) + }; + + configure.Limit = 10; + configure.WindowSize = TimeSpan.FromMinutes(2); + }). + AddTimeBasedRateLimiting(configure => + { + configure.RuleConditions = new List> + { + token => token.Region == Region.us || token.Region == Region.eu, + token => !string.IsNullOrEmpty(token.UserId) + }; + + configure.MinTimeBetweenRequests = TimeSpan.FromSeconds(10); + }); +``` + + +Rules are applied based on conditions. If the condition is not specified then it applies to all requests + +```csharp + configure.RuleConditions = new List> + { + token => token.Region == Region.us, + token => !string.IsNullOrEmpty(token.UserId) + }; +``` + +### Set up the RateLimitMiddleware + +Add the RateLimit middleware to your `Configure` method in the `Startup` class or directly into your `IWebHostBuilder`. + +_Example:_ + +```csharp +app.UseRateLimiting(); +``` + + + +**Rate-limiting pattern** Rate limiting involves restricting the number of requests that a client can make. A client is identified with an access token, which is used for every request to a resource. diff --git a/RateLimiter.SampleApi/Controllers/ValuesController.cs b/RateLimiter.SampleApi/Controllers/ValuesController.cs new file mode 100644 index 00000000..29440e4d --- /dev/null +++ b/RateLimiter.SampleApi/Controllers/ValuesController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace RateLimiter.SampleApi.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ValuesController : ControllerBase + { + } +} diff --git a/RateLimiter.SampleApi/Controllers/WeatherForecastController.cs b/RateLimiter.SampleApi/Controllers/WeatherForecastController.cs new file mode 100644 index 00000000..01a8d71c --- /dev/null +++ b/RateLimiter.SampleApi/Controllers/WeatherForecastController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; + +namespace RateLimiter.SampleApi.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 = DateTime.Now.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }) + .ToArray(); + } +} diff --git a/RateLimiter.SampleApi/Program.cs b/RateLimiter.SampleApi/Program.cs new file mode 100644 index 00000000..dc4018f4 --- /dev/null +++ b/RateLimiter.SampleApi/Program.cs @@ -0,0 +1,55 @@ +using RateLimiter; +using RateLimiter.Extensions; + +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. + AddFixedWindow(configure => + { + configure.RuleConditions = new List> + { + token => token.Region == Region.us, + token => !string.IsNullOrEmpty(token.UserId) + }; + + configure.Limit = 10; + configure.WindowSize = TimeSpan.FromMinutes(2); + }). + AddTimeBasedRateLimiting(configure => + { + configure.RuleConditions = new List> + { + token => token.Region == Region.us || token.Region == Region.eu, + token => !string.IsNullOrEmpty(token.UserId) + }; + + configure.MinTimeBetweenRequests = TimeSpan.FromSeconds(10); + }); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseRateLimiting(); + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + diff --git a/RateLimiter.SampleApi/Properties/launchSettings.json b/RateLimiter.SampleApi/Properties/launchSettings.json new file mode 100644 index 00000000..3464ccb2 --- /dev/null +++ b/RateLimiter.SampleApi/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61262", + "sslPort": 44376 + } + }, + "profiles": { + "RateLimiter.SampleApi": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7103;http://localhost:5109", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/RateLimiter.SampleApi/RateLimiter.SampleApi.csproj b/RateLimiter.SampleApi/RateLimiter.SampleApi.csproj new file mode 100644 index 00000000..90e42bf6 --- /dev/null +++ b/RateLimiter.SampleApi/RateLimiter.SampleApi.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + diff --git a/RateLimiter.SampleApi/RateLimiter.SampleApi.csproj.user b/RateLimiter.SampleApi/RateLimiter.SampleApi.csproj.user new file mode 100644 index 00000000..848dffb8 --- /dev/null +++ b/RateLimiter.SampleApi/RateLimiter.SampleApi.csproj.user @@ -0,0 +1,8 @@ + + + + ApiControllerEmptyScaffolder + root/Common/Api + 650 + + \ No newline at end of file diff --git a/RateLimiter.SampleApi/WeatherForecast.cs b/RateLimiter.SampleApi/WeatherForecast.cs new file mode 100644 index 00000000..55ea6439 --- /dev/null +++ b/RateLimiter.SampleApi/WeatherForecast.cs @@ -0,0 +1,13 @@ +namespace RateLimiter.SampleApi +{ + public class WeatherForecast + { + public DateTime 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.SampleApi/appsettings.Development.json b/RateLimiter.SampleApi/appsettings.Development.json new file mode 100644 index 00000000..ff66ba6b --- /dev/null +++ b/RateLimiter.SampleApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/RateLimiter.SampleApi/appsettings.json b/RateLimiter.SampleApi/appsettings.json new file mode 100644 index 00000000..4d566948 --- /dev/null +++ b/RateLimiter.SampleApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/RateLimiter.Tests/MiddlewareTest.cs b/RateLimiter.Tests/MiddlewareTest.cs new file mode 100644 index 00000000..9fd8dc51 --- /dev/null +++ b/RateLimiter.Tests/MiddlewareTest.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using NUnit.Framework; +using RateLimiter.Rules; +using RateLimiter.Rules.FixedWindowRule; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class MiddlewareTest + { + private IMemoryCache _cache; + + [SetUp] + public void Setup() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + } + + [Test] + public async Task Middleware_AllowsRequest_WhenWithinLimit() + { + var options = new FixedWindowRuleOptions + { + RuleConditions = null, //rules apply to all requests + WindowSize = TimeSpan.FromMinutes(1), + Limit = 5 + }; + + var rules = new List { new FixedWindowRule(options, _cache) }; + var middleware = new RateLimitMiddleware(async (context) => { context.Response.StatusCode = 200; }, rules); + + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("127.0.0.1"); + context.Request.Path = "/test"; + + await middleware.InvokeAsync(context); + Assert.AreEqual(200, context.Response.StatusCode); + } + + [Test] + public async Task Middleware_BlocksRequest_WhenLimitExceeded() + { + var options = new FixedWindowRuleOptions + { + WindowSize = TimeSpan.FromMinutes(2), + Limit = 1 + }; + + var rules = new List { new FixedWindowRule(options, _cache) }; + var middleware = new RateLimitMiddleware(async (context) => { context.Response.StatusCode = 200; }, rules); + + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("127.0.0.1"); + context.Request.Path = "/test"; + + await middleware.InvokeAsync(context); + Assert.AreEqual(200, context.Response.StatusCode); + + context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("127.0.0.1"); + context.Request.Path = "/test"; + + await middleware.InvokeAsync(context); + Assert.AreEqual(StatusCodes.Status429TooManyRequests, context.Response.StatusCode); + } + } +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..be7ee9c0 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -8,6 +8,8 @@ + + diff --git a/RateLimiter.Tests/Rules/FixedWindowRuleTest.cs b/RateLimiter.Tests/Rules/FixedWindowRuleTest.cs new file mode 100644 index 00000000..cd9df259 --- /dev/null +++ b/RateLimiter.Tests/Rules/FixedWindowRuleTest.cs @@ -0,0 +1,104 @@ +using NUnit.Framework; +using RateLimiter.Rules.FixedWindowRule; +using System; +using System.Collections.Generic; + +namespace RateLimiter.Tests.Rules; + +[TestFixture] +public class FixedWindowRuleTest: TestBase +{ + [Test] + public void FixedWindowRateLimit_AllowsRequestsWithinLimit_ValidConditions() + { + var options = new FixedWindowRuleOptions + { + RuleConditions = new List> + { + token => token.Region == Region.eu, + token => !string.IsNullOrEmpty(token.UserId) + }, + WindowSize = TimeSpan.FromMinutes(1), + Limit = 5 + }; + + var rule = new FixedWindowRule(options, _cache); + var token = new AccessToken("test_client", Region.eu); + var route = "/test"; + + for (int i = 0; i < options.Limit; i++) + { + Assert.IsTrue(rule.IsRequestAllowed(token, route)); + } + } + + [Test] + public void FixedWindowRateLimit_AllowsRequestsWithinLimit_NoConditions() + { + var options = new FixedWindowRuleOptions + { + WindowSize = TimeSpan.FromMinutes(1), + Limit = 5 + }; + + var rule = new FixedWindowRule(options, _cache); + var token = new AccessToken("test_client", Region.eu); + var route = "/test"; + + for (int i = 0; i < options.Limit; i++) + { + Assert.IsTrue(rule.IsRequestAllowed(token, route)); + } + } + + [Test] + public void FixedWindowRateLimit_BlocksRequestsBeyondLimit() + { + var options = new FixedWindowRuleOptions + { + RuleConditions = new List> + { + token => token.Region == Region.eu, + }, + WindowSize = TimeSpan.FromMinutes(1), + Limit = 3 + + }; + + var rule = new FixedWindowRule(options, _cache); + var token = new AccessToken("test_client", Region.eu); + var route = "/test"; + + for (int i = 0; i < options.Limit; i++) + { + Assert.IsTrue(rule.IsRequestAllowed(token, route)); + } + + Assert.IsFalse(rule.IsRequestAllowed(token, route)); + } + + + [Test] + public void FixedWindowRateLimit_DoNotApplyRule() + { + var options = new FixedWindowRuleOptions + { + RuleConditions = new List> + { + token => token.Region == Region.eu, + }, + WindowSize = TimeSpan.FromMinutes(1), + Limit = 3 + + }; + + var rule = new FixedWindowRule(options, _cache); + var token = new AccessToken("test_client", Region.us); + var route = "/test"; + + for (int i = 0; i <= options.Limit + 5; i++) + { + Assert.IsTrue(rule.IsRequestAllowed(token, route)); + } + } +} diff --git a/RateLimiter.Tests/Rules/TimeBasedRuleTest.cs b/RateLimiter.Tests/Rules/TimeBasedRuleTest.cs new file mode 100644 index 00000000..589597dd --- /dev/null +++ b/RateLimiter.Tests/Rules/TimeBasedRuleTest.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using RateLimiter.Rules.TimeBasedRule; +using System; +using System.Collections.Generic; +using System.Threading; + +namespace RateLimiter.Tests.Rules; + +[TestFixture] +public class TimeBasedRuleTest: TestBase +{ + + [Test] + public void TimeBasedRateLimit_AllowsRequestsAfterTimeInterval() + { + var options = new TimeBasedRuleOptions + { + MinTimeBetweenRequests = TimeSpan.FromSeconds(1) + }; + + var rule = new TimeBasedRule(options, _cache); + + var token = new AccessToken("test_client", Region.eu); + var route = "/test"; + + Assert.IsTrue(rule.IsRequestAllowed(token, route)); + Assert.IsFalse(rule.IsRequestAllowed(token, route)); + + Thread.Sleep(1100); + Assert.IsTrue(rule.IsRequestAllowed(token, route)); + } + + [Test] + public void TimeBasedRateLimit_DoNotApplyRuleForCurrentCall() + { + var options = new TimeBasedRuleOptions + { + RuleConditions = new List> + { + token => token.Region == Region.us, + }, + MinTimeBetweenRequests = TimeSpan.FromSeconds(1) + }; + + var rule = new TimeBasedRule(options, _cache); + + var token = new AccessToken("test_client", Region.eu); + var route = "/test"; + + for (int i = 0; i <= 10; i++) + { + Assert.IsTrue(rule.IsRequestAllowed(token, route)); + } + } +} diff --git a/RateLimiter.Tests/TestBase.cs b/RateLimiter.Tests/TestBase.cs new file mode 100644 index 00000000..6f3dc912 --- /dev/null +++ b/RateLimiter.Tests/TestBase.cs @@ -0,0 +1,15 @@ +using NUnit.Framework; +using Microsoft.Extensions.Caching.Memory; + +namespace RateLimiter.Tests; + +public class TestBase +{ + protected IMemoryCache _cache; + + [SetUp] + public void Setup() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + } +} diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..eef688fe 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,36 +1,42 @@ - -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.13.35825.156 d17.13 +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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.SampleApi", "RateLimiter.SampleApi\RateLimiter.SampleApi.csproj", "{019BE76B-DA6E-419D-82AE-C665ACFAF827}" +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 + {019BE76B-DA6E-419D-82AE-C665ACFAF827}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {019BE76B-DA6E-419D-82AE-C665ACFAF827}.Debug|Any CPU.Build.0 = Debug|Any CPU + {019BE76B-DA6E-419D-82AE-C665ACFAF827}.Release|Any CPU.ActiveCfg = Release|Any CPU + {019BE76B-DA6E-419D-82AE-C665ACFAF827}.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/AccessToken.cs b/RateLimiter/AccessToken.cs new file mode 100644 index 00000000..8823e8be --- /dev/null +++ b/RateLimiter/AccessToken.cs @@ -0,0 +1,9 @@ +namespace RateLimiter; + +public enum Region +{ + us = 1, + eu = 2, +} + +public record AccessToken(string UserId, Region Region = Region.us); diff --git a/RateLimiter/Extensions/RateLimiterExtensions.cs b/RateLimiter/Extensions/RateLimiterExtensions.cs new file mode 100644 index 00000000..089b2f44 --- /dev/null +++ b/RateLimiter/Extensions/RateLimiterExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using System; +using RateLimiter.Rules; +using RateLimiter.Rules.FixedWindowRule; +using RateLimiter.Rules.TimeBasedRule; + +namespace RateLimiter.Extensions; +public static class RateLimiterExtensions +{ + private static IServiceCollection AddRateLimitRule( + this IServiceCollection services, + Action configure, + Func ruleFactory) where TRule : class, IRateLimitRule/*, new()*/ where TOptions : IRateLimitRuleOptions, new() + { + services.AddSingleton(sp => + { + var cache = sp.GetRequiredService(); + var options = new TOptions(); + configure(options); + return ruleFactory(cache, options); + }); + return services; + } + + public static IServiceCollection AddFixedWindow(this IServiceCollection services, Action configure) + { + return services.AddRateLimitRule(configure, (cache, options) => new FixedWindowRule(options, cache)); + } + + public static IServiceCollection AddTimeBasedRateLimiting(this IServiceCollection services, Action configure) + { + return services.AddRateLimitRule(configure, (cache, options) => new TimeBasedRule(options, cache)); + } + + public static IApplicationBuilder UseRateLimiting(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} + + + diff --git a/RateLimiter/RateLimitMiddleware.cs b/RateLimiter/RateLimitMiddleware.cs new file mode 100644 index 00000000..954fcb66 --- /dev/null +++ b/RateLimiter/RateLimitMiddleware.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using RateLimiter.Rules; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace RateLimiter; + +public class RateLimitMiddleware +{ + private readonly RequestDelegate _next; + private readonly IEnumerable _rateLimitRules; + + public RateLimitMiddleware(RequestDelegate next, IEnumerable rateLimitRules) + { + _next = next; + _rateLimitRules = rateLimitRules; + } + + public async Task InvokeAsync(HttpContext context) + { + var route = context.Request.Path.ToString(); + var clientId = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + var token = new AccessToken(clientId); + + if (_rateLimitRules.Any(rule => !rule.IsRequestAllowed(token, route))) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsync("Rate limit exceeded. Try again later."); + return; + } + + await _next(context); + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..fb2d31e9 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,10 @@ latest enable + + + + + + \ No newline at end of file diff --git a/RateLimiter/Rules/FixedWindowRule/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule/FixedWindowRule.cs new file mode 100644 index 00000000..663dc98f --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule/FixedWindowRule.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace RateLimiter.Rules.FixedWindowRule; + +public class FixedWindowRule : RateLimitRule +{ + private readonly IMemoryCache _cache; + + public FixedWindowRule( + FixedWindowRuleOptions options, + IMemoryCache cache) + : base(options) + { + _cache = cache; + } + + public override bool IsRequestAllowed(AccessToken token, string route) + { + if (!ValidateCondition(token)) + return true; + + var cacheKey = $"{Options.Name}_{token.UserId}_{route}"; + var requestCount = _cache.Get(cacheKey) ?? 0; + + if (requestCount >= Options.Limit) + { + return false; + } + + _cache.Set(cacheKey, requestCount + 1, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = Options.WindowSize + }); + + return true; + } +} diff --git a/RateLimiter/Rules/FixedWindowRule/FixedWindowRuleOptions.cs b/RateLimiter/Rules/FixedWindowRule/FixedWindowRuleOptions.cs new file mode 100644 index 00000000..40cb41b3 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule/FixedWindowRuleOptions.cs @@ -0,0 +1,10 @@ +using System; + +namespace RateLimiter.Rules.FixedWindowRule; + +public class FixedWindowRuleOptions : RateLimitRuleOptions +{ + public string Name { get; set; } = "FixedWindowRule"; + public int Limit { get; set; } + public TimeSpan WindowSize { get; set; } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..949ea335 --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Rules; + +public interface IRateLimitRule +{ + bool IsRequestAllowed(AccessToken token, string route); +} diff --git a/RateLimiter/Rules/IRateLimitRuleOptions.cs b/RateLimiter/Rules/IRateLimitRuleOptions.cs new file mode 100644 index 00000000..c6c20dbc --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRuleOptions.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter.Rules; + +public interface IRateLimitRuleOptions +{ + string Name { get; set; } + List>? RuleConditions { get; set; } +} diff --git a/RateLimiter/Rules/RateLimitRule.cs b/RateLimiter/Rules/RateLimitRule.cs new file mode 100644 index 00000000..b1f0c133 --- /dev/null +++ b/RateLimiter/Rules/RateLimitRule.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Linq; + +namespace RateLimiter.Rules; + +public abstract class RateLimitRule : IRateLimitRule where T : IRateLimitRuleOptions +{ + protected ILogger Logger { get; } + + protected T Options { get; } + + protected RateLimitRule(T options) : this(options, null) { } + protected RateLimitRule(T options, ILogger? logger = null) + { + Options = options; + Logger = logger ?? NullLoggerFactory.Instance.CreateLogger>(); + } + + public abstract bool IsRequestAllowed(AccessToken token, string route); + + protected bool ValidateCondition(AccessToken token) + { + //apply rule based on condition or for all requests if condition is not set + return Options.RuleConditions == null || Options.RuleConditions.All(condition => condition(token)); + } +} diff --git a/RateLimiter/Rules/RateLimitRuleOptions.cs b/RateLimiter/Rules/RateLimitRuleOptions.cs new file mode 100644 index 00000000..0223bee7 --- /dev/null +++ b/RateLimiter/Rules/RateLimitRuleOptions.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter.Rules; + +public class RateLimitRuleOptions : IRateLimitRuleOptions +{ + public string Name { get; set; } = ""; + + public List>? RuleConditions { get; set; } +} diff --git a/RateLimiter/Rules/TimeBasedRule/TimeBasedRule.cs b/RateLimiter/Rules/TimeBasedRule/TimeBasedRule.cs new file mode 100644 index 00000000..d351e4ed --- /dev/null +++ b/RateLimiter/Rules/TimeBasedRule/TimeBasedRule.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Caching.Memory; +using System; + +namespace RateLimiter.Rules.TimeBasedRule; + +public class TimeBasedRule : RateLimitRule +{ + private readonly IMemoryCache _cache; + + public TimeBasedRule( + TimeBasedRuleOptions options, + IMemoryCache cache) + : base(options) + { + _cache = cache; + } + + public override bool IsRequestAllowed(AccessToken token, string route) + { + if (!ValidateCondition(token)) + return true; + + var key = $"{Options.Name}_{token.UserId}_{route}"; + var lastRequestTime = _cache.Get(key); + + if (lastRequestTime.HasValue && (DateTime.UtcNow - lastRequestTime.Value) < Options.MinTimeBetweenRequests) + { + return false; + } + + _cache.Set(key, DateTime.UtcNow, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = Options.MinTimeBetweenRequests }); + return true; + } +} + diff --git a/RateLimiter/Rules/TimeBasedRule/TimeBasedRuleOptions.cs b/RateLimiter/Rules/TimeBasedRule/TimeBasedRuleOptions.cs new file mode 100644 index 00000000..b5a8a8cd --- /dev/null +++ b/RateLimiter/Rules/TimeBasedRule/TimeBasedRuleOptions.cs @@ -0,0 +1,10 @@ +using System; + +namespace RateLimiter.Rules.TimeBasedRule +{ + public class TimeBasedRuleOptions: RateLimitRuleOptions + { + public string Name { get; set; } = "TimeBasedRule"; + public TimeSpan MinTimeBetweenRequests { get; set; } = TimeSpan.FromSeconds(10); + } +}