diff --git a/RateLimiter.Tests/Examples/UsageExamples.cs b/RateLimiter.Tests/Examples/UsageExamples.cs new file mode 100644 index 00000000..2d79f15a --- /dev/null +++ b/RateLimiter.Tests/Examples/UsageExamples.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using RateLimiter.Abstractions; +using RateLimiter.Extensions; +using RateLimiter.Rules; + +namespace RateLimiter.Tests.Examples +{ + [TestFixture] + public class UsageExamples + { + [Test] + public void Example_BasicConfiguration() + { + var services = new ServiceCollection(); + + services.AddRateLimiter(options => + { + var requestCountRule = new RequestCountRule(new Storage.InMemoryRequestTracker()) + { + MaxRequests = 100, + TimeWindow = TimeSpan.FromMinutes(1) + }; + + options.DefaultRules.Add(requestCountRule); + }); + + var serviceProvider = services.BuildServiceProvider(); + + Assert.Pass("Example code compiles successfully"); + } + + [Test] + public void Example_AdvancedConfiguration() + { + var services = new ServiceCollection(); + + services.AddRateLimiter(options => + { + var requestTracker = new Storage.InMemoryRequestTracker(); + + var requestCountRule = new RequestCountRule(requestTracker) + { + MaxRequests = 100, + TimeWindow = TimeSpan.FromMinutes(1) + }; + + var timeSinceLastCallRule = new TimeSinceLastCallRule(requestTracker) + { + MinInterval = TimeSpan.FromSeconds(1) + }; + + var regionRule = new RegionCompositeRule(requestCountRule); + + var euRule = new TimeSinceLastCallRule(requestTracker) + { + MinInterval = TimeSpan.FromSeconds(2) + }; + regionRule.AddRegionRule("EU", euRule); + + options.DefaultRules.Add(regionRule); + + options.EndpointRules.Add("/api/high-load", new List + { + new RequestCountRule(requestTracker) + { + MaxRequests = 10, + TimeWindow = TimeSpan.FromMinutes(1) + } + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + Assert.Pass("Example code compiles successfully"); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Http/RateLimiterMiddlewareTests.cs b/RateLimiter.Tests/Http/RateLimiterMiddlewareTests.cs new file mode 100644 index 00000000..bced5eb9 --- /dev/null +++ b/RateLimiter.Tests/Http/RateLimiterMiddlewareTests.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using RateLimiter.Abstractions; +using RateLimiter.Configuration; +using RateLimiter.Http; +using RateLimiter.Models; + +namespace RateLimiter.Tests.Http +{ + [TestFixture] + public class RateLimiterMiddlewareTests + { + private Mock _mockNext = null!; + private Mock _mockContextFactory = null!; + private Mock> _mockOptionsMonitor = null!; + private Mock _mockRule = null!; + private RateLimiterMiddleware _middleware = null!; + private DefaultHttpContext _httpContext = null!; + private RequestContext _requestContext = null!; + private RateLimitOptions _options = null!; + + [SetUp] + public void Setup() + { + _mockNext = new Mock(); + _mockContextFactory = new Mock(); + _mockOptionsMonitor = new Mock>(); + _mockRule = new Mock(); + + _middleware = new RateLimiterMiddleware(_mockNext.Object, _mockContextFactory.Object); + + _httpContext = new DefaultHttpContext(); + _httpContext.Request.Path = "/api/resource"; + + _requestContext = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource" + }; + + _options = new RateLimitOptions(); + _options.DefaultRules.Add(_mockRule.Object); + + _mockContextFactory.Setup(f => f.Create(It.IsAny())) + .Returns(_requestContext); + + _mockOptionsMonitor.Setup(o => o.CurrentValue) + .Returns(_options); + } + + [Test] + public async Task InvokeAsync_WhenRateLimitAllowed_CallsNextMiddleware() + { + _mockRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + await _middleware.InvokeAsync(_httpContext, _mockOptionsMonitor.Object); + + _mockNext.Verify(n => n(_httpContext), Times.Once); + Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + } + + [Test] + public async Task InvokeAsync_WhenRateLimitExceeded_Returns429() + { + _mockRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(false); + + await _middleware.InvokeAsync(_httpContext, _mockOptionsMonitor.Object); + + _mockNext.Verify(n => n(It.IsAny()), Times.Never); + Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(StatusCodes.Status429TooManyRequests)); + Assert.That(_httpContext.Response.Headers.ContainsKey("Retry-After"), Is.True); + } + + [Test] + public async Task InvokeAsync_WithMultipleRules_ChecksAllRules() + { + var mockRule2 = new Mock(); + _options.DefaultRules.Add(mockRule2.Object); + + _mockRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + mockRule2.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + await _middleware.InvokeAsync(_httpContext, _mockOptionsMonitor.Object); + + _mockRule.Verify(s => s.ValidateAsync(_requestContext), Times.Once); + mockRule2.Verify(s => s.ValidateAsync(_requestContext), Times.Once); + _mockNext.Verify(n => n(_httpContext), Times.Once); + } + + [Test] + public async Task InvokeAsync_FirstRuleFails_DoesNotCheckSecondRule() + { + var mockRule2 = new Mock(); + _options.DefaultRules.Add(mockRule2.Object); + + _mockRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(false); + + await _middleware.InvokeAsync(_httpContext, _mockOptionsMonitor.Object); + + _mockRule.Verify(s => s.ValidateAsync(_requestContext), Times.Once); + mockRule2.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + _mockNext.Verify(n => n(It.IsAny()), Times.Never); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Integration/RateLimiterIntegrationTests.cs b/RateLimiter.Tests/Integration/RateLimiterIntegrationTests.cs new file mode 100644 index 00000000..41f166b8 --- /dev/null +++ b/RateLimiter.Tests/Integration/RateLimiterIntegrationTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using RateLimiter.Abstractions; +using RateLimiter.Configuration; +using RateLimiter.Extensions; +using RateLimiter.Http; +using RateLimiter.Rules; + +namespace RateLimiter.Tests.Integration +{ + [TestFixture] + public class RateLimiterIntegrationTests + { + private IServiceProvider _serviceProvider = null!; + private DefaultHttpContext _httpContext = null!; + + [SetUp] + public void Setup() + { + var services = new ServiceCollection(); + + services.AddRateLimiter(options => + { + var requestCountRule = new RequestCountRule(new Storage.InMemoryRequestTracker()) + { + MaxRequests = 3, + TimeWindow = TimeSpan.FromSeconds(10) + }; + + options.DefaultRules.Add(requestCountRule); + }); + + _serviceProvider = services.BuildServiceProvider(); + + _httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider + }; + _httpContext.Request.Path = "/api/resource"; + _httpContext.Request.Headers.Add("Authorization", "test-token"); + } + + [Test] + public async Task RateLimiter_AllowsRequestsUpToLimit() + { + var middleware = new RateLimiterMiddleware( + next: (context) => Task.CompletedTask, + contextFactory: _serviceProvider.GetRequiredService() + ); + + var options = _serviceProvider.GetRequiredService>(); + + for (int i = 0; i < 3; i++) + { + await middleware.InvokeAsync(_httpContext, options); + Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + } + + await middleware.InvokeAsync(_httpContext, options); + Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(StatusCodes.Status429TooManyRequests)); + } + + [Test] + public async Task RateLimiter_TracksRequestsPerClientAndResource() + { + var middleware = new RateLimiterMiddleware( + next: (context) => Task.CompletedTask, + contextFactory: _serviceProvider.GetRequiredService() + ); + + var options = _serviceProvider.GetRequiredService>(); + + for (int i = 0; i < 3; i++) + { + _httpContext.Request.Path = "/api/resource1"; + await middleware.InvokeAsync(_httpContext, options); + Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + } + + _httpContext.Request.Path = "/api/resource1"; + await middleware.InvokeAsync(_httpContext, options); + Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(StatusCodes.Status429TooManyRequests)); + + _httpContext.Request.Path = "/api/resource2"; + _httpContext.Response.StatusCode = StatusCodes.Status200OK; + await middleware.InvokeAsync(_httpContext, options); + Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..b489b3b4 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -8,8 +8,11 @@ + + + - \ No newline at end of file + \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/CompositeRuleTests.cs b/RateLimiter.Tests/Rules/CompositeRuleTests.cs new file mode 100644 index 00000000..63b53ab1 --- /dev/null +++ b/RateLimiter.Tests/Rules/CompositeRuleTests.cs @@ -0,0 +1,93 @@ +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using RateLimiter.Abstractions; +using RateLimiter.Models; +using RateLimiter.Rules; + +namespace RateLimiter.Tests.Rules +{ + [TestFixture] + public class CompositeRuleTests + { + private Mock _mockRule1 = null!; + private Mock _mockRule2 = null!; + private CompositeRule _compositeRule = null!; + private RequestContext _context = null!; + + [SetUp] + public void Setup() + { + _mockRule1 = new Mock(); + _mockRule2 = new Mock(); + _compositeRule = new CompositeRule(); + _context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource" + }; + } + + [Test] + public async Task ValidateAsync_NoRules_ReturnsTrue() + { + var result = await _compositeRule.ValidateAsync(_context); + + Assert.That(result, Is.True); + } + + [Test] + public async Task ValidateAsync_AllRulesPass_ReturnsTrue() + { + _mockRule1.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + _mockRule2.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + _compositeRule.AddRule(_mockRule1.Object); + _compositeRule.AddRule(_mockRule2.Object); + + var result = await _compositeRule.ValidateAsync(_context); + + Assert.That(result, Is.True); + _mockRule1.Verify(s => s.ValidateAsync(_context), Times.Once); + _mockRule2.Verify(s => s.ValidateAsync(_context), Times.Once); + } + + [Test] + public async Task ValidateAsync_FirstRuleFails_ReturnsFalse() + { + _mockRule1.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(false); + _mockRule2.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + _compositeRule.AddRule(_mockRule1.Object); + _compositeRule.AddRule(_mockRule2.Object); + + var result = await _compositeRule.ValidateAsync(_context); + + Assert.That(result, Is.False); + _mockRule1.Verify(s => s.ValidateAsync(_context), Times.Once); + _mockRule2.Verify(s => s.ValidateAsync(_context), Times.Never); + } + + [Test] + public async Task ValidateAsync_SecondRuleFails_ReturnsFalse() + { + _mockRule1.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + _mockRule2.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(false); + + _compositeRule.AddRule(_mockRule1.Object); + _compositeRule.AddRule(_mockRule2.Object); + + var result = await _compositeRule.ValidateAsync(_context); + + Assert.That(result, Is.False); + _mockRule1.Verify(s => s.ValidateAsync(_context), Times.Once); + _mockRule2.Verify(s => s.ValidateAsync(_context), Times.Once); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/RegionCompositeRuleTests.cs b/RateLimiter.Tests/Rules/RegionCompositeRuleTests.cs new file mode 100644 index 00000000..f8c770e3 --- /dev/null +++ b/RateLimiter.Tests/Rules/RegionCompositeRuleTests.cs @@ -0,0 +1,114 @@ +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using RateLimiter.Abstractions; +using RateLimiter.Models; +using RateLimiter.Rules; + +namespace RateLimiter.Tests.Rules +{ + [TestFixture] + public class RegionCompositeRuleTests + { + private Mock _mockDefaultRule = null!; + private Mock _mockUsRule = null!; + private Mock _mockEuRule = null!; + private RegionCompositeRule _regionRule = null!; + + [SetUp] + public void Setup() + { + _mockDefaultRule = new Mock(); + _mockUsRule = new Mock(); + _mockEuRule = new Mock(); + + _regionRule = new RegionCompositeRule(_mockDefaultRule.Object); + _regionRule.AddRegionRule("US", _mockUsRule.Object); + _regionRule.AddRegionRule("EU", _mockEuRule.Object); + } + + [Test] + public async Task ValidateAsync_WithUsRegion_UsesUsRule() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource", + Region = "US" + }; + + _mockUsRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + var result = await _regionRule.ValidateAsync(context); + + Assert.That(result, Is.True); + _mockUsRule.Verify(s => s.ValidateAsync(context), Times.Once); + _mockEuRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + _mockDefaultRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task ValidateAsync_WithEuRegion_UsesEuRule() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource", + Region = "EU" + }; + + _mockEuRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + var result = await _regionRule.ValidateAsync(context); + + Assert.That(result, Is.True); + _mockEuRule.Verify(s => s.ValidateAsync(context), Times.Once); + _mockUsRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + _mockDefaultRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task ValidateAsync_WithUnknownRegion_UsesDefaultRule() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource", + Region = "UNKNOWN" + }; + + _mockDefaultRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + var result = await _regionRule.ValidateAsync(context); + + Assert.That(result, Is.True); + _mockDefaultRule.Verify(s => s.ValidateAsync(context), Times.Once); + _mockUsRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + _mockEuRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task ValidateAsync_WithEmptyRegion_UsesDefaultRule() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource", + Region = string.Empty + }; + + _mockDefaultRule.Setup(s => s.ValidateAsync(It.IsAny())) + .ReturnsAsync(true); + + var result = await _regionRule.ValidateAsync(context); + + Assert.That(result, Is.True); + _mockDefaultRule.Verify(s => s.ValidateAsync(context), Times.Once); + _mockUsRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + _mockEuRule.Verify(s => s.ValidateAsync(It.IsAny()), Times.Never); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/RequestCountRuleTests.cs b/RateLimiter.Tests/Rules/RequestCountRuleTests.cs new file mode 100644 index 00000000..ab409471 --- /dev/null +++ b/RateLimiter.Tests/Rules/RequestCountRuleTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; +using RateLimiter.Storage; + +namespace RateLimiter.Tests.Rules +{ + [TestFixture] + public class RequestCountRuleTests + { + private InMemoryRequestTracker _tracker = null!; + private RequestCountRule _rule = null!; + + [SetUp] + public void Setup() + { + _tracker = new InMemoryRequestTracker(); + _rule = new RequestCountRule(_tracker) + { + MaxRequests = 3, + TimeWindow = TimeSpan.FromSeconds(10) + }; + } + + [Test] + public async Task ValidateAsync_WhenUnderLimit_ReturnsTrue() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource" + }; + + var result1 = await _rule.ValidateAsync(context); + var result2 = await _rule.ValidateAsync(context); + var result3 = await _rule.ValidateAsync(context); + + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + Assert.That(result3, Is.True); + } + + [Test] + public async Task ValidateAsync_WhenOverLimit_ReturnsFalse() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource" + }; + + var result1 = await _rule.ValidateAsync(context); + var result2 = await _rule.ValidateAsync(context); + var result3 = await _rule.ValidateAsync(context); + var result4 = await _rule.ValidateAsync(context); + + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + Assert.That(result3, Is.True); + Assert.That(result4, Is.False); + } + + [Test] + public async Task ValidateAsync_WithDifferentResources_TracksIndependently() + { + var context1 = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource1" + }; + + var context2 = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource2" + }; + + await _rule.ValidateAsync(context1); + await _rule.ValidateAsync(context1); + await _rule.ValidateAsync(context1); + var result1 = await _rule.ValidateAsync(context1); + + var result2 = await _rule.ValidateAsync(context2); + + Assert.That(result1, Is.False, "Resource1 should be rate limited"); + Assert.That(result2, Is.True, "Resource2 should not be rate limited"); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/TimeSinceLastCallRuleTests.cs b/RateLimiter.Tests/Rules/TimeSinceLastCallRuleTests.cs new file mode 100644 index 00000000..f3027116 --- /dev/null +++ b/RateLimiter.Tests/Rules/TimeSinceLastCallRuleTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; +using RateLimiter.Storage; + +namespace RateLimiter.Tests.Rules +{ + [TestFixture] + public class TimeSinceLastCallRuleTests + { + private InMemoryRequestTracker _tracker = null!; + private TimeSinceLastCallRule _rule = null!; + + [SetUp] + public void Setup() + { + _tracker = new InMemoryRequestTracker(); + _rule = new TimeSinceLastCallRule(_tracker) + { + MinInterval = TimeSpan.FromSeconds(1) + }; + } + + [Test] + public async Task ValidateAsync_FirstRequest_ReturnsTrue() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource" + }; + + var result = await _rule.ValidateAsync(context); + + Assert.That(result, Is.True); + } + + [Test] + public async Task ValidateAsync_SecondRequestTooSoon_ReturnsFalse() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource" + }; + + var result1 = await _rule.ValidateAsync(context); + var result2 = await _rule.ValidateAsync(context); + + Assert.That(result1, Is.True); + Assert.That(result2, Is.False); + } + + [Test] + public async Task ValidateAsync_SecondRequestAfterInterval_ReturnsTrue() + { + var context = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource" + }; + + var result1 = await _rule.ValidateAsync(context); + + await Task.Delay(TimeSpan.FromSeconds(1.1)); + + var result2 = await _rule.ValidateAsync(context); + + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + } + + [Test] + public async Task ValidateAsync_DifferentResources_TrackedIndependently() + { + var context1 = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource1" + }; + + var context2 = new RequestContext + { + ClientToken = "test-token", + ResourcePath = "/api/resource2" + }; + + var result1 = await _rule.ValidateAsync(context1); + var result2 = await _rule.ValidateAsync(context2); + + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRateLimitRule.cs b/RateLimiter/Abstractions/IRateLimitRule.cs new file mode 100644 index 00000000..580db8b9 --- /dev/null +++ b/RateLimiter/Abstractions/IRateLimitRule.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using RateLimiter.Models; + +namespace RateLimiter.Abstractions +{ + public interface IRateLimitRule + { + Task ValidateAsync(RequestContext context); + } +} \ No newline at end of file diff --git a/RateLimiter/Abstractions/IRequestContextFactory.cs b/RateLimiter/Abstractions/IRequestContextFactory.cs new file mode 100644 index 00000000..31052002 --- /dev/null +++ b/RateLimiter/Abstractions/IRequestContextFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; +using RateLimiter.Models; + +namespace RateLimiter.Abstractions +{ + public interface IRequestContextFactory + { + RequestContext Create(HttpContext httpContext); + } +} \ No newline at end of file diff --git a/RateLimiter/Configuration/RateLimitOptions.cs b/RateLimiter/Configuration/RateLimitOptions.cs new file mode 100644 index 00000000..d70b60f1 --- /dev/null +++ b/RateLimiter/Configuration/RateLimitOptions.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using RateLimiter.Abstractions; + +namespace RateLimiter.Configuration +{ + public class RateLimitOptions + { + public List DefaultRules { get; set; } = new List(); + + public Dictionary> EndpointRules { get; set; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public IEnumerable GetRulesForEndpoint(string path) + { + foreach (var rule in DefaultRules) + { + yield return rule; + } + + foreach (var kvp in EndpointRules) + { + if (path.StartsWith(kvp.Key, StringComparison.OrdinalIgnoreCase)) + { + foreach (var rule in kvp.Value) + { + yield return rule; + } + } + } + } + } +} \ No newline at end of file diff --git a/RateLimiter/Extensions/ApplicationBuilderExtensions.cs b/RateLimiter/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 00000000..876420c8 --- /dev/null +++ b/RateLimiter/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Builder; +using RateLimiter.Http; + +namespace RateLimiter.Extensions +{ + public static class ApplicationBuilderExtensions + { + public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Extensions/ServiceCollectionExtensions.cs b/RateLimiter/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..ae50b194 --- /dev/null +++ b/RateLimiter/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using RateLimiter.Abstractions; +using RateLimiter.Configuration; +using RateLimiter.Http; +using RateLimiter.Storage; + +namespace RateLimiter.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddRateLimiter( + this IServiceCollection services, + Action? configureOptions = null) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + + var optionsBuilder = services.AddOptions(); + + if (configureOptions != null) + { + optionsBuilder.Configure(configureOptions); + } + + return services; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Http/DefaultRequestContextFactory.cs b/RateLimiter/Http/DefaultRequestContextFactory.cs new file mode 100644 index 00000000..7ae12816 --- /dev/null +++ b/RateLimiter/Http/DefaultRequestContextFactory.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.AspNetCore.Http; +using RateLimiter.Abstractions; +using RateLimiter.Models; + +namespace RateLimiter.Http +{ + public class DefaultRequestContextFactory : IRequestContextFactory + { + private const string DefaultRegion = "GLOBAL"; + private const string RegionHeaderName = "X-Region"; + private const string TokenHeaderName = "Authorization"; + + public RequestContext Create(HttpContext httpContext) + { + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var context = new RequestContext + { + ResourcePath = httpContext.Request.Path.Value ?? string.Empty, + Timestamp = DateTime.UtcNow + }; + + if (httpContext.Request.Headers.TryGetValue(TokenHeaderName, out var authHeader)) + { + var authValue = authHeader.ToString(); + + if (authValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + context.ClientToken = authValue.Substring(7).Trim(); + } + else + { + context.ClientToken = authValue.Trim(); + } + } + + if (httpContext.Request.Headers.TryGetValue(RegionHeaderName, out var regionHeader)) + { + context.Region = regionHeader.ToString().Trim(); + } + else + { + context.Region = DefaultRegion; + } + + return context; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Http/RateLimiterMiddleware.cs b/RateLimiter/Http/RateLimiterMiddleware.cs new file mode 100644 index 00000000..206e1fb5 --- /dev/null +++ b/RateLimiter/Http/RateLimiterMiddleware.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using RateLimiter.Abstractions; +using RateLimiter.Configuration; + +namespace RateLimiter.Http +{ + public class RateLimiterMiddleware + { + private readonly RequestDelegate _next; + private readonly IRequestContextFactory _contextFactory; + + public RateLimiterMiddleware(RequestDelegate next, IRequestContextFactory contextFactory) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _contextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); + } + + public async Task InvokeAsync(HttpContext context, IOptionsMonitor options) + { + var requestContext = _contextFactory.Create(context); + var currentOptions = options.CurrentValue; + + var rules = currentOptions.GetRulesForEndpoint(requestContext.ResourcePath); + + foreach (var rule in rules) + { + var isAllowed = await rule.ValidateAsync(requestContext); + + if (!isAllowed) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers.Add("Retry-After", "1"); + await context.Response.WriteAsync("Rate limit exceeded. Please try again later."); + return; + } + } + + await _next(context); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Models/RequestContext.cs b/RateLimiter/Models/RequestContext.cs new file mode 100644 index 00000000..2897c941 --- /dev/null +++ b/RateLimiter/Models/RequestContext.cs @@ -0,0 +1,15 @@ +using System; + +namespace RateLimiter.Models +{ + public class RequestContext + { + public string ClientToken { get; set; } = string.Empty; + + public string ResourcePath { get; set; } = string.Empty; + + public string Region { get; set; } = string.Empty; + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..cf47ae79 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,10 @@ latest enable - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/RateLimiter/Rules/CompositeRule.cs b/RateLimiter/Rules/CompositeRule.cs new file mode 100644 index 00000000..cd763a6c --- /dev/null +++ b/RateLimiter/Rules/CompositeRule.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using RateLimiter.Abstractions; +using RateLimiter.Models; + +namespace RateLimiter.Rules +{ + public class CompositeRule : IRateLimitRule + { + private readonly List _rules = new List(); + + public CompositeRule() + { + } + + public CompositeRule(IEnumerable rules) + { + if (rules == null) + { + throw new ArgumentNullException(nameof(rules)); + } + + _rules.AddRange(rules); + } + + public void AddRule(IRateLimitRule rule) + { + if (rule == null) + { + throw new ArgumentNullException(nameof(rule)); + } + + _rules.Add(rule); + } + + public async Task ValidateAsync(RequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (_rules.Count == 0) + { + return true; + } + + foreach (var rule in _rules) + { + if (!await rule.ValidateAsync(context)) + { + return false; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/RegionCompositeRule.cs b/RateLimiter/Rules/RegionCompositeRule.cs new file mode 100644 index 00000000..0223ac22 --- /dev/null +++ b/RateLimiter/Rules/RegionCompositeRule.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using RateLimiter.Abstractions; +using RateLimiter.Models; + +namespace RateLimiter.Rules +{ + public class RegionCompositeRule : IRateLimitRule + { + private readonly Dictionary _regionRules; + private readonly IRateLimitRule _defaultRule; + + public RegionCompositeRule(IRateLimitRule defaultRule) + { + _defaultRule = defaultRule ?? throw new ArgumentNullException(nameof(defaultRule)); + _regionRules = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public void AddRegionRule(string region, IRateLimitRule rule) + { + if (string.IsNullOrWhiteSpace(region)) + { + throw new ArgumentException("Region cannot be null or empty", nameof(region)); + } + + _regionRules[region] = rule ?? throw new ArgumentNullException(nameof(rule)); + } + + public Task ValidateAsync(RequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var rule = GetRuleForRegion(context.Region); + + return rule.ValidateAsync(context); + } + + private IRateLimitRule GetRuleForRegion(string region) + { + if (!string.IsNullOrWhiteSpace(region) && _regionRules.TryGetValue(region, out var rule)) + { + return rule; + } + + return _defaultRule; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/RequestCountRule.cs b/RateLimiter/Rules/RequestCountRule.cs new file mode 100644 index 00000000..fd826e3d --- /dev/null +++ b/RateLimiter/Rules/RequestCountRule.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using RateLimiter.Abstractions; +using RateLimiter.Models; +using RateLimiter.Storage; + +namespace RateLimiter.Rules +{ + public class RequestCountRule : IRateLimitRule + { + private readonly IConcurrentRequestTracker _requestTracker; + + public int MaxRequests { get; set; } + + public TimeSpan TimeWindow { get; set; } + + public RequestCountRule(IConcurrentRequestTracker requestTracker) + { + _requestTracker = requestTracker ?? throw new ArgumentNullException(nameof(requestTracker)); + MaxRequests = 100; + TimeWindow = TimeSpan.FromMinutes(1); + } + + public async Task ValidateAsync(RequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var requestCount = await _requestTracker.RecordRequestAndCountAsync(context, TimeWindow); + + return requestCount <= MaxRequests; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/TimeSinceLastCallRule.cs b/RateLimiter/Rules/TimeSinceLastCallRule.cs new file mode 100644 index 00000000..4727cf40 --- /dev/null +++ b/RateLimiter/Rules/TimeSinceLastCallRule.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using RateLimiter.Abstractions; +using RateLimiter.Models; +using RateLimiter.Storage; + +namespace RateLimiter.Rules +{ + public class TimeSinceLastCallRule : IRateLimitRule + { + private readonly IConcurrentRequestTracker _requestTracker; + + public TimeSpan MinInterval { get; set; } + + public TimeSinceLastCallRule(IConcurrentRequestTracker requestTracker) + { + _requestTracker = requestTracker ?? throw new ArgumentNullException(nameof(requestTracker)); + MinInterval = TimeSpan.FromSeconds(1); + } + + public async Task ValidateAsync(RequestContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var lastRequestTime = await _requestTracker.GetLastRequestTimeAsync(context); + + if (!lastRequestTime.HasValue) + { + await _requestTracker.RecordRequestTimeAsync(context); + return true; + } + + var timeSinceLastRequest = DateTime.UtcNow - lastRequestTime.Value; + + var isAllowed = timeSinceLastRequest >= MinInterval; + + if (isAllowed) + { + await _requestTracker.RecordRequestTimeAsync(context); + } + + return isAllowed; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Storage/IConcurrentRequestTracker.cs b/RateLimiter/Storage/IConcurrentRequestTracker.cs new file mode 100644 index 00000000..972f1d29 --- /dev/null +++ b/RateLimiter/Storage/IConcurrentRequestTracker.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading.Tasks; +using RateLimiter.Models; + +namespace RateLimiter.Storage +{ + public interface IConcurrentRequestTracker + { + Task RecordRequestAndCountAsync(RequestContext context, TimeSpan timeWindow); + + Task GetLastRequestTimeAsync(RequestContext context); + + Task RecordRequestTimeAsync(RequestContext context); + } +} \ No newline at end of file diff --git a/RateLimiter/Storage/InMemoryRequestTracker.cs b/RateLimiter/Storage/InMemoryRequestTracker.cs new file mode 100644 index 00000000..9b4e7d24 --- /dev/null +++ b/RateLimiter/Storage/InMemoryRequestTracker.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using RateLimiter.Models; + +namespace RateLimiter.Storage +{ + public class InMemoryRequestTracker : IConcurrentRequestTracker + { + private readonly ConcurrentDictionary> _requestHistory = + new ConcurrentDictionary>(); + + private readonly ConcurrentDictionary _lastRequestTime = + new ConcurrentDictionary(); + + private static string CreateKey(RequestContext context) => + $"{context.ClientToken}:{context.ResourcePath}"; + + public Task RecordRequestAndCountAsync(RequestContext context, TimeSpan timeWindow) + { + var key = CreateKey(context); + var now = DateTime.UtcNow; + var cutoff = now.Subtract(timeWindow); + + var history = _requestHistory.GetOrAdd(key, _ => new ConcurrentQueue()); + + history.Enqueue(now); + + var count = 0; + var oldRequests = new List(); + + foreach (var timestamp in history) + { + if (timestamp >= cutoff) + { + count++; + } + else + { + oldRequests.Add(timestamp); + } + } + + foreach (var oldRequest in oldRequests) + { + history.TryDequeue(out _); + } + + return Task.FromResult(count); + } + + public Task GetLastRequestTimeAsync(RequestContext context) + { + var key = CreateKey(context); + + if (_lastRequestTime.TryGetValue(key, out var timestamp)) + { + return Task.FromResult(timestamp); + } + + return Task.FromResult(null); + } + + public Task RecordRequestTimeAsync(RequestContext context) + { + var key = CreateKey(context); + _lastRequestTime[key] = DateTime.UtcNow; + return Task.CompletedTask; + } + } +} \ No newline at end of file