diff --git a/RateLimiter.API/Controllers/SampleItemsController.cs b/RateLimiter.API/Controllers/SampleItemsController.cs new file mode 100644 index 00000000..b74a6207 --- /dev/null +++ b/RateLimiter.API/Controllers/SampleItemsController.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using RateLimiter.API.Models; + +namespace RateLimiter.API.Controllers; + +[ApiController] +[Route("[controller]")] +public class SampleItemsController : ControllerBase +{ + private static readonly Dictionary _items = new(); + private static int _currentId = 0; + + [HttpGet] + public IActionResult GetAll() + { + return Ok(_items.Values); + } + + [HttpGet("{id}")] + public IActionResult GetById(int id) + { + if (_items.TryGetValue(id, out var item)) + { + return Ok(item); + } + + return NotFound(); + } + + [HttpPost] + public IActionResult Create(SampleItemModel item) + { + item.Id = ++_currentId; + _items[item.Id] = item; + return CreatedAtAction(nameof(GetById), new { id = item.Id }, item); + } + + [HttpPut("{id}")] + public IActionResult Update(int id, SampleItemModel item) + { + if (_items.ContainsKey(id)) + { + item.Id = id; + _items[id] = item; + return Ok(item); + } + + return NotFound(); + } + + [HttpDelete("{id}")] + public IActionResult Delete(int id) + { + if (_items.Remove(id, out var item)) + { + return Ok(item); + } + + return NotFound(); + } +} \ No newline at end of file diff --git a/RateLimiter.API/Models/SampleItemModel.cs b/RateLimiter.API/Models/SampleItemModel.cs new file mode 100644 index 00000000..b4aa92a2 --- /dev/null +++ b/RateLimiter.API/Models/SampleItemModel.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.API.Models; + +public class SampleItemModel +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/RateLimiter.API/Program.cs b/RateLimiter.API/Program.cs new file mode 100644 index 00000000..6f307868 --- /dev/null +++ b/RateLimiter.API/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using RateLimiter.API.Controllers; +using RateLimiter.Implementation; + +namespace RateLimiter.API; + +public class Program +{ + public static void Main(string[] args) + { + var hostBuilder = new HostBuilder() + .ConfigureHostConfiguration(configHost => + { + configHost.SetBasePath(Directory.GetCurrentDirectory()); + configHost.AddJsonFile("appsettings.json", optional: true); + configHost.AddEnvironmentVariables(prefix: "ASPNETCORE_"); + configHost.AddCommandLine(args); + }) + .ConfigureServices((hostContext, services) => + { + var rateLimitConfig = new RateLimitConfig(); + hostContext.Configuration.GetSection("RateLimiting").Bind(rateLimitConfig); + + foreach (var ruleConfig in rateLimitConfig.Rules) + { + var ruleType = Type.GetType(ruleConfig.RuleType); + if (ruleType == null) continue; + + var rule = (IRateLimitingRule)Activator.CreateInstance(ruleType, ruleConfig.Parameters)!; + services.AddSingleton(typeof(IRateLimitingRule), rule); + } + + services.AddSingleton(rateLimitConfig); + }); + + var host = hostBuilder.Build(); + host.Run(); + } +} \ No newline at end of file diff --git a/RateLimiter.API/RateLimiter.API.csproj b/RateLimiter.API/RateLimiter.API.csproj new file mode 100644 index 00000000..d184defa --- /dev/null +++ b/RateLimiter.API/RateLimiter.API.csproj @@ -0,0 +1,20 @@ + + + + Exe + net6.0 + enable + enable + latest + + + + + + + + + + + + diff --git a/RateLimiter.API/appsettings.json b/RateLimiter.API/appsettings.json new file mode 100644 index 00000000..bb35621a --- /dev/null +++ b/RateLimiter.API/appsettings.json @@ -0,0 +1,78 @@ +{ + "RateLimiting": { + "Endpoints": [ + { + "Path": "/GetById", + "AppliedRules": [ + { + "RuleName": "TimespanSinceLastCall", + "Parameters": { + "MinimumInterval": "00:00:05" + } + }, + { + "RuleName": "RequestsPerTimespan", + "Parameters": { + "RequestLimit": 1024, + "Timespan": "01:00:00" + } + } + ] + }, + { + "Path": "/Create", + "AppliedRules": [ + { + "RuleName": "TimespanSinceLastCall", + "Parameters": { + "MinimumInterval": "00:00:10" + } + }, + { + "RuleName": "RequestsPerTimespan", + "Parameters": { + "RequestLimit": 512, + "Timespan": "00:30:00" + } + } + ] + }, + { + "Path": "/Update", + "AppliedRules": [ + { + "RuleName": "TimespanSinceLastCall", + "Parameters": { + "MinimumInterval": "00:02:10" + } + }, + { + "RuleName": "RequestsPerTimespan", + "Parameters": { + "RequestLimit": 128, + "Timespan": "00:06:00" + } + } + ] + }, + { + "Path": "/Delete", + "AppliedRules": [ + { + "RuleName": "TimespanSinceLastCall", + "Parameters": { + "MinimumInterval": "00:01:00" + } + }, + { + "RuleName": "RequestsPerTimespan", + "Parameters": { + "RequestLimit": 256, + "Timespan": "00:15:00" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..74a39c74 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..90968931 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,90 @@ -using NUnit.Framework; +using System; +using Moq; +using NUnit.Framework; +using RateLimiter.Implementation.Rules; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } + [Test] + public void IsRequestAllowed_RequestAfterMinimumInterval_ReturnsTrue() + { + // Arrange + var ruleMock = new Mock(TimeSpan.FromMinutes(1)); + + var clientId = "testClient"; + var resource = "testResource"; + ruleMock + .Setup(r => r.GetLastRequestTime(clientId, resource)) + .Returns(DateTime.UtcNow.AddMinutes(-25)); + + // Act + var result = ruleMock.Object.IsRequestAllowed(clientId, resource); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void IsRequestAllowed_RequestBeforeMinimumInterval_ReturnsFalse() + { + // Arrange + var rule = new Mock(TimeSpan.FromMinutes(1)); + var clientId = "testClient"; + var resource = "testResource"; + + rule.Object.SaveRequest(clientId, resource, DateTime.UtcNow); + + // Act + var result = rule.Object.IsRequestAllowed(clientId, resource); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void IsRequestAllowed_RequestsBelowMaxWithinTimespan_ReturnsTrue() + { + // Arrange + var maxRequests = 5; + var timespan = TimeSpan.FromHours(1); + var rule = new RequestsPerTimespanRule(maxRequests, timespan); + var clientId = "testClient"; + var resource = "testResource"; + + for (int i = 0; i < maxRequests - 1; i++) + { + rule.SaveRequest(clientId, resource, DateTime.UtcNow.AddMinutes(-i * 256)); + } + + // Act + var result = rule.IsRequestAllowed(clientId, resource); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void IsRequestAllowed_RequestsExceedMaxWithinTimespan_ReturnsFalse() + { + // Arrange + var maxRequests = 5; + var timespan = TimeSpan.FromHours(1); + var rule = new RequestsPerTimespanRule(maxRequests, timespan); + var clientId = "testClient"; + var resource = "testResource"; + + for (int i = 0; i < maxRequests; i++) + { + rule.SaveRequest(clientId, resource, DateTime.UtcNow.AddMinutes(-i * 10)); + } + + // Act + var result = rule.IsRequestAllowed(clientId, resource); + + // Assert + Assert.IsFalse(result); + } } \ No newline at end of file diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..fb9c7d6f 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.API", "RateLimiter.API\RateLimiter.API.csproj", "{1182DAAD-13CF-4412-8044-761B33A95FB3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +28,10 @@ Global {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.Build.0 = Release|Any CPU + {1182DAAD-13CF-4412-8044-761B33A95FB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1182DAAD-13CF-4412-8044-761B33A95FB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1182DAAD-13CF-4412-8044-761B33A95FB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1182DAAD-13CF-4412-8044-761B33A95FB3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/RateLimiter/BaseRule.cs b/RateLimiter/BaseRule.cs new file mode 100644 index 00000000..b1b06e59 --- /dev/null +++ b/RateLimiter/BaseRule.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter; + +public abstract class BaseRule : IRateLimitingRule +{ + private static readonly ConcurrentDictionary> Requests = new(); + + public abstract bool IsRequestAllowed(string clientId, string resource); + + public virtual DateTime? GetLastRequestTime(string clientId, string resource) + { + var key = $"{clientId}:{resource}"; + if (Requests.TryGetValue(key, out var timestamps) && timestamps.Any()) + { + return timestamps.Last(); + } + return null; + } + + protected int GetRequestCount(string clientId, string resource, DateTime start, DateTime end) + { + var key = $"{clientId}:{resource}"; + if (Requests.TryGetValue(key, out var timestamps)) + { + return timestamps.Count(timestamp => timestamp >= start && timestamp <= end); + } + return 0; + } + + public void SaveRequest(string clientId, string resource, DateTime timestamp) + { + var key = $"{clientId}:{resource}"; + Requests.AddOrUpdate(key, new List { timestamp }, (_, timestampList) => + { + timestampList.Add(timestamp); + return timestampList; + }); + } +} \ No newline at end of file diff --git a/RateLimiter/IRateLimitingRule.cs b/RateLimiter/IRateLimitingRule.cs new file mode 100644 index 00000000..a43f34f0 --- /dev/null +++ b/RateLimiter/IRateLimitingRule.cs @@ -0,0 +1,6 @@ +namespace RateLimiter; + +public interface IRateLimitingRule +{ + bool IsRequestAllowed(string clientId, string resource); +} \ No newline at end of file diff --git a/RateLimiter/Implementation/EndpointConfig.cs b/RateLimiter/Implementation/EndpointConfig.cs new file mode 100644 index 00000000..fea625ab --- /dev/null +++ b/RateLimiter/Implementation/EndpointConfig.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace RateLimiter.Implementation; + +public class EndpointConfig +{ + public string Path { get; set; } + public List AppliedRules { get; set; } = new List(); +} \ No newline at end of file diff --git a/RateLimiter/Implementation/RateLimitConfig.cs b/RateLimiter/Implementation/RateLimitConfig.cs new file mode 100644 index 00000000..69976358 --- /dev/null +++ b/RateLimiter/Implementation/RateLimitConfig.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace RateLimiter.Implementation; + +public class RateLimitConfig +{ + public List Rules { get; set; } = new List(); + public List Endpoints { get; set; } = new List(); +} \ No newline at end of file diff --git a/RateLimiter/Implementation/RuleApplication.cs b/RateLimiter/Implementation/RuleApplication.cs new file mode 100644 index 00000000..f0451be8 --- /dev/null +++ b/RateLimiter/Implementation/RuleApplication.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace RateLimiter.Implementation; + +public class RuleApplication +{ + public string RuleName { get; set; } + public Dictionary Parameters { get; set; } = new(); +} \ No newline at end of file diff --git a/RateLimiter/Implementation/RuleConfig.cs b/RateLimiter/Implementation/RuleConfig.cs new file mode 100644 index 00000000..ff00050a --- /dev/null +++ b/RateLimiter/Implementation/RuleConfig.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace RateLimiter.Implementation; + +public class RuleConfig +{ + public string RuleType { get; set; } + public Dictionary Parameters { get; set; } = new(); +} \ No newline at end of file diff --git a/RateLimiter/Implementation/Rules/RequestsPerTimespanRule.cs b/RateLimiter/Implementation/Rules/RequestsPerTimespanRule.cs new file mode 100644 index 00000000..30472068 --- /dev/null +++ b/RateLimiter/Implementation/Rules/RequestsPerTimespanRule.cs @@ -0,0 +1,27 @@ +using System; + +namespace RateLimiter.Implementation.Rules; + +public class RequestsPerTimespanRule : BaseRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _timespan; + + public RequestsPerTimespanRule(int maxRequests, TimeSpan timespan) + { + _maxRequests = maxRequests; + _timespan = timespan; + } + + public override bool IsRequestAllowed(string clientId, string resource) + { + var now = DateTime.UtcNow; + var requests = GetRequestCount(clientId, resource, now - _timespan, now); + if (requests < _maxRequests) + { + SaveRequest(clientId, resource, now); + return true; + } + return false; + } +} \ No newline at end of file diff --git a/RateLimiter/Implementation/Rules/TimespanSinceLastCallRule.cs b/RateLimiter/Implementation/Rules/TimespanSinceLastCallRule.cs new file mode 100644 index 00000000..5ded8dd1 --- /dev/null +++ b/RateLimiter/Implementation/Rules/TimespanSinceLastCallRule.cs @@ -0,0 +1,24 @@ +using System; + +namespace RateLimiter.Implementation.Rules; + +public class TimespanSinceLastCallRule : BaseRule +{ + private readonly TimeSpan _minimumInterval; + + public TimespanSinceLastCallRule(TimeSpan minimumInterval) + { + _minimumInterval = minimumInterval; + } + + public override bool IsRequestAllowed(string clientId, string resource) + { + var lastRequestTime = GetLastRequestTime(clientId, resource); + if (lastRequestTime != null && DateTime.UtcNow - lastRequestTime >= _minimumInterval) + { + SaveRequest(clientId, resource, DateTime.UtcNow); + return true; + } + return false; + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..929bc00d 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,8 @@ latest enable + + + + \ No newline at end of file