diff --git a/README.md b/README.md
index 47e73daa..0c608072 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-**Rate-limiting pattern**
+***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.
@@ -8,20 +8,14 @@ The client makes an API call to a particular resource; the server checks whether
If the request is within the limit, then the request goes through.
Otherwise, the API call is restricted.
-Some examples of request-limiting rules (you could imagine any others)
-* X requests per timespan;
-* a certain timespan has passed since the last call;
-* For US-based tokens, we use X requests per timespan; for EU-based tokens, a certain timespan has passed since the last call.
+**To run the examples:**
+run `dotnet run` in RateLimiter
-The goal is to design a class(-es) that manages each API resource's rate limits by a set of provided *configurable and extendable* rules. For example, for one resource, you could configure the limiter to use Rule A; for another one - Rule B; for a third one - both A + B, etc. Any combination of rules should be possible; keep this fact in mind when designing the classes.
+Working example URLs:
+- https://localhost:5001/api/test (token bucket rule)
+- https://localhost:5001/api/orders (fixed window rule)
+- https://localhost:5001/api/orders/item (region based rule)
+- https://localhost:5001/api/users (composite rule)
-We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm. There is no need to use a database (in-memory storage is fine) or any web framework. Do not waste time on preparing complex environment, reusable class library covered by a set of tests is more than enough.
-
-There is a Test Project set up for you to use. However, you are welcome to create your own test project and use whatever test runner you like.
-
-You are welcome to ask any questions regarding the requirements—treat us as product owners, analysts, or whoever knows the business.
-If you have any questions or concerns, please submit them as a [GitHub issue](https://github.com/crexi-dev/rate-limiter/issues).
-
-You should [fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) the project and [create a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) named as `FirstName LastName` once you are finished.
-
-Good luck!
+**To run tests:**
+run `dotnet test` in RateLimiter.Tests
\ No newline at end of file
diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj
index 5cbfc4e8..62e63183 100644
--- a/RateLimiter.Tests/RateLimiter.Tests.csproj
+++ b/RateLimiter.Tests/RateLimiter.Tests.csproj
@@ -1,6 +1,6 @@
- net6.0
+ net8.0
latest
enable
@@ -12,4 +12,4 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs
index 172d44a7..7d56da4b 100644
--- a/RateLimiter.Tests/RateLimiterTest.cs
+++ b/RateLimiter.Tests/RateLimiterTest.cs
@@ -1,13 +1,63 @@
-using NUnit.Framework;
+using System;
+using NUnit.Framework;
+using RateLimiter.Core;
+using RateLimiter.Core.Rules;
-namespace RateLimiter.Tests;
-
-[TestFixture]
-public class RateLimiterTest
+namespace RateLimiter.Tests
{
- [Test]
- public void Example()
- {
- Assert.That(true, Is.True);
- }
+ [TestFixture]
+ public class RateLimiterTest
+ {
+ [Test]
+ public void TestNoRulesConfigured()
+ {
+ var rateLimiter = new Core.RateLimiter();
+ string clientToken = "test-client";
+ string resourceId = "api/no-rule";
+
+ // should allow all requests when no rule is configured
+ for (int i = 0; i < 12; i++)
+ {
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True,
+ $"Request {i+1} should be allowed when no rule is configured");
+ }
+ }
+
+ [Test]
+ public void TestUsesCorrectRule()
+ {
+ var rateLimiter = new Core.RateLimiter();
+ var rule = new FixedWindowRule(2, TimeSpan.FromSeconds(10));
+
+ rateLimiter.ConfigureResource("api/test", rule);
+
+ string clientToken = "test-client";
+ string resourceId = "api/test";
+
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "Second request should be allowed");
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.False, "Third request should be denied");
+ }
+
+ [Test]
+ public void TestMultipleResources()
+ {
+ var rateLimiter = new Core.RateLimiter();
+
+ // configure different rules for different resources
+ rateLimiter.ConfigureResource("api/resource1", new FixedWindowRule(1, TimeSpan.FromSeconds(10)));
+ rateLimiter.ConfigureResource("api/resource2", new FixedWindowRule(2, TimeSpan.FromSeconds(10)));
+
+ string clientToken = "test-client";
+
+ // should allow 1 request
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.True, "First request to resource1 should be allowed");
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.False, "Second request to resource1 should be denied");
+
+ // should allow 2 requests
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "First request to resource2 should be allowed");
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "Second request to resource2 should be allowed");
+ Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.False, "Third request to resource2 should be denied");
+ }
+ }
}
\ No newline at end of file
diff --git a/RateLimiter.Tests/Rules/CompositeRuleTest.cs b/RateLimiter.Tests/Rules/CompositeRuleTest.cs
new file mode 100644
index 00000000..1505a2a9
--- /dev/null
+++ b/RateLimiter.Tests/Rules/CompositeRuleTest.cs
@@ -0,0 +1,41 @@
+using System;
+using NUnit.Framework;
+using RateLimiter.Core.Rules;
+
+namespace RateLimiter.Tests
+{
+ [TestFixture]
+ public class CompositeRuleTest
+ {
+ [Test]
+ public void TestCompositeAnd()
+ {
+ var rule1 = new FixedWindowRule(2, TimeSpan.FromSeconds(10)); // 2 requests max
+ var rule2 = new FixedWindowRule(3, TimeSpan.FromSeconds(10)); // 3 requests max
+ var compositeRule = new CompositeAndRule(rule1, rule2);
+
+ string clientToken = "test-client";
+ string resourceId = "api/test";
+
+ Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed by both rules");
+ Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed by both rules");
+ Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.False, "Third request should be denied by rule1");
+ }
+
+ [Test]
+ public void TestCompositeOr()
+ {
+ var rule1 = new FixedWindowRule(1, TimeSpan.FromSeconds(10)); // 1 request max
+ var rule2 = new TokenBucketRule(2, 0); // allow 2 requests
+ var compositeRule = new CompositeOrRule(rule1, rule2);
+
+ string clientToken = "test-client";
+ string resourceId = "api/test";
+
+ Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed by both rules");
+ Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed by rule2");
+ Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed by 1 rule");
+ Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied by both rules");
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter.Tests/Rules/FixedWindowTest.cs b/RateLimiter.Tests/Rules/FixedWindowTest.cs
new file mode 100644
index 00000000..52a1effd
--- /dev/null
+++ b/RateLimiter.Tests/Rules/FixedWindowTest.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Threading;
+using NUnit.Framework;
+using RateLimiter.Core.Rules;
+
+namespace RateLimiter.Tests
+{
+ [TestFixture]
+ public class FixedWindowTest
+ {
+ [Test]
+ public void TestAllowAndDeny()
+ {
+ // create a fixed window rule with max 3 requests per 10-second window
+ var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(10));
+ string clientToken = "test-client";
+ string resourceId = "api/test";
+
+ // should allow exactly 3 requests
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied");
+ }
+
+ [Test]
+ public void TestWindowReset()
+ {
+ // create a fixed window rule with max 1 request per 1 sec window
+ var rule = new FixedWindowRule(1, TimeSpan.FromSeconds(1));
+ string clientToken = "test-client";
+ string resourceId = "api/test";
+
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Second request in same window should be denied");
+
+ // wait for token to reset
+ Thread.Sleep(1100);
+
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Request in new window should be allowed");
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter.Tests/Rules/RegionBasedTest.cs b/RateLimiter.Tests/Rules/RegionBasedTest.cs
new file mode 100644
index 00000000..f87b63af
--- /dev/null
+++ b/RateLimiter.Tests/Rules/RegionBasedTest.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using RateLimiter.Core.Rules;
+
+namespace RateLimiter.Tests
+{
+ [TestFixture]
+ public class RegionBasedRuleTest
+ {
+ private string GetRegion(string token)
+ {
+ if (token.StartsWith("EU"))
+ return "EU";
+ return "US";
+ }
+
+ [Test]
+ public void TestApplyCorrectRuleForRegion()
+ {
+ // US rule allows 2 requests, EU rule allows 1 request
+ var usRule = new FixedWindowRule(2, TimeSpan.FromSeconds(10));
+ var euRule = new FixedWindowRule(1, TimeSpan.FromSeconds(10));
+
+ var regionRules = new Dictionary
+ {
+ { "US", usRule },
+ { "EU", euRule }
+ };
+
+ var rule = new RegionBasedRule(regionRules, GetRegion);
+
+ // US client should get 2 requests
+ Assert.That(rule.IsAllowed("US-client", "api/test"), Is.True, "First US request should be allowed");
+ Assert.That(rule.IsAllowed("US-client", "api/test"), Is.True, "Second US request should be allowed");
+ Assert.That(rule.IsAllowed("US-client", "api/test"), Is.False, "Third US request should be denied");
+
+ // EU client should get 1 request
+ Assert.That(rule.IsAllowed("EU-client", "api/test"), Is.True, "First EU request should be allowed");
+ Assert.That(rule.IsAllowed("EU-client", "api/test"), Is.False, "Second EU request should be denied");
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter.Tests/Rules/TokenBucketTest.cs b/RateLimiter.Tests/Rules/TokenBucketTest.cs
new file mode 100644
index 00000000..b5fb6fe4
--- /dev/null
+++ b/RateLimiter.Tests/Rules/TokenBucketTest.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Threading;
+using NUnit.Framework;
+using RateLimiter.Core.Rules;
+
+namespace RateLimiter.Tests
+{
+ [TestFixture]
+ public class TokenBucketTest
+ {
+ [Test]
+ public void TestTokenBucketAllowAndDeny()
+ {
+ // create a token bucket with 3 tokens and no refill
+ var rule = new TokenBucketRule(3, 0);
+ string clientToken = "test-client";
+ string resourceId = "api/test";
+
+ // should allow exactly 3 requests
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied");
+ }
+
+ [Test]
+ public void TestTokenBucketWithRefill()
+ {
+ // create a token bucket with 1 token that refills at 1 token per second
+ var rule = new TokenBucketRule(1, 1.0);
+ string clientToken = "test-client";
+ string resourceId = "api/test";
+
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Request before refill should be denied");
+
+ // wait for token to refill
+ Thread.Sleep(1100);
+
+ Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Request after refill should be allowed");
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Builders/RuleBuilder.cs b/RateLimiter/Builders/RuleBuilder.cs
new file mode 100644
index 00000000..f7522b9a
--- /dev/null
+++ b/RateLimiter/Builders/RuleBuilder.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using RateLimiter.Core.Rules;
+
+namespace RateLimiter.Builders
+{
+ public static class RuleBuilder
+ {
+ public static IRateLimitRule CreateFixedWindowRule(int maxRequests, TimeSpan window)
+ {
+ return new FixedWindowRule(maxRequests, window);
+ }
+
+ public static IRateLimitRule CreateTokenBucketRule(int capacity, double refillRate)
+ {
+ return new TokenBucketRule(capacity, refillRate);
+ }
+
+ public static IRateLimitRule CreateRegionBasedRule(Dictionary regionRules, Func regionExtractor, IRateLimitRule? defaultRule = null)
+ {
+ return new RegionBasedRule(regionRules, regionExtractor, defaultRule);
+ }
+
+ public static IRateLimitRule CreateCompositeAndRule(params IRateLimitRule[] rules)
+ {
+ return new CompositeAndRule(rules);
+ }
+
+ public static IRateLimitRule CreateCompositeOrRule(params IRateLimitRule[] rules)
+ {
+ return new CompositeOrRule(rules);
+ }
+
+ public static IRateLimitRule CreateRegionRules()
+ {
+ // set region rules
+ var usRule = new FixedWindowRule(5, TimeSpan.FromMinutes(1));
+ var euRule = new TokenBucketRule(3, 3/60.0);
+
+ // create region mapping
+ var regionRules = new Dictionary
+ {
+ { "US", usRule },
+ { "EU", euRule }
+ };
+
+ // create region rules
+ return CreateRegionBasedRule(regionRules, GetRegion);
+ }
+
+ private static string GetRegion(string token)
+ {
+ if (token.StartsWith("EU"))
+ {
+ return "EU";
+ }
+ return "US";
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Controllers/OrdersController.cs b/RateLimiter/Controllers/OrdersController.cs
new file mode 100644
index 00000000..554538d8
--- /dev/null
+++ b/RateLimiter/Controllers/OrdersController.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Mvc;
+using System;
+
+namespace RateLimiter.Controllers
+{
+ [ApiController]
+ [Route("api/[controller]")]
+ public class OrdersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get()
+ {
+ return Ok(new
+ {
+ message = "Orders API request successful!!",
+ timestamp = DateTime.UtcNow
+ });
+ }
+
+ [HttpGet("item")]
+ public IActionResult GetItem()
+ {
+ return Ok(new
+ {
+ message = "Orders item API request successful!!",
+ timestamp = DateTime.UtcNow
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Controllers/TestController.cs b/RateLimiter/Controllers/TestController.cs
new file mode 100644
index 00000000..6ae0cf8e
--- /dev/null
+++ b/RateLimiter/Controllers/TestController.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Mvc;
+using System;
+
+namespace RateLimiter.Controllers
+{
+ [ApiController]
+ [Route("api/[controller]")]
+ public class TestController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get()
+ {
+ return Ok(new
+ {
+ message = "API request successful!!",
+ timestamp = DateTime.UtcNow
+ });
+ }
+
+ [HttpGet("slow")]
+ public IActionResult GetSlow()
+ {
+ // simulate a slow endpoint
+ System.Threading.Thread.Sleep(500);
+ return Ok(new
+ {
+ message = "Slow API request successful",
+ timestamp = DateTime.UtcNow
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Controllers/UsersController.cs b/RateLimiter/Controllers/UsersController.cs
new file mode 100644
index 00000000..4c3b5ef9
--- /dev/null
+++ b/RateLimiter/Controllers/UsersController.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Mvc;
+using System;
+
+namespace RateLimiter.Controllers
+{
+ [ApiController]
+ [Route("api/[controller]")]
+ public class UsersController : ControllerBase
+ {
+ [HttpGet]
+ public IActionResult Get()
+ {
+ return Ok(new
+ {
+ message = "Users API request successful!!",
+ timestamp = DateTime.UtcNow
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Core/RateLimiter.cs b/RateLimiter/Core/RateLimiter.cs
new file mode 100644
index 00000000..caf2592b
--- /dev/null
+++ b/RateLimiter/Core/RateLimiter.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using RateLimiter.Core.Rules;
+
+namespace RateLimiter.Core
+{
+ public class RateLimiter
+{
+ private readonly ConcurrentDictionary _resourceRules = new();
+
+ // configure a rule for a resource
+ public void ConfigureResource(string resourceId, IRateLimitRule rule)
+ {
+ _resourceRules[resourceId] = rule;
+ }
+
+ // check if a request should be allowed
+ public bool IsRequestAllowed(string clientToken, string resourceId)
+ {
+ // if no rule for this resource, allow the request
+ if (!_resourceRules.TryGetValue(resourceId, out var rule)) {
+ return true;
+ }
+
+ // check the rule
+ return rule.IsAllowed(clientToken, resourceId);
+ }
+}
+}
\ No newline at end of file
diff --git a/RateLimiter/Core/Rules/CompositeAndRule.cs b/RateLimiter/Core/Rules/CompositeAndRule.cs
new file mode 100644
index 00000000..64f63e61
--- /dev/null
+++ b/RateLimiter/Core/Rules/CompositeAndRule.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Concurrent;
+using Microsoft.AspNetCore.Routing;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RateLimiter.Core.Rules
+{
+ public class CompositeAndRule : IRateLimitRule
+ {
+ private readonly List _rules;
+
+ public CompositeAndRule(params IRateLimitRule[] rules)
+ {
+ _rules = rules.ToList();
+ }
+
+ public bool IsAllowed(string clientToken, string resourceId)
+ {
+ // all rules must allow the request
+ return _rules.All(rule => rule.IsAllowed(clientToken, resourceId));
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Core/Rules/CompositeOrRule.cs b/RateLimiter/Core/Rules/CompositeOrRule.cs
new file mode 100644
index 00000000..ac28a641
--- /dev/null
+++ b/RateLimiter/Core/Rules/CompositeOrRule.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Concurrent;
+using Microsoft.AspNetCore.Routing;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RateLimiter.Core.Rules
+{
+ public class CompositeOrRule : IRateLimitRule
+ {
+ private readonly List _rules;
+
+ public CompositeOrRule(params IRateLimitRule[] rules)
+ {
+ _rules = rules.ToList();
+ }
+
+ public bool IsAllowed(string clientToken, string resourceId)
+ {
+ // at least one rule must allow the request
+ return _rules.Any(rule => rule.IsAllowed(clientToken, resourceId));
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Core/Rules/FixedWindowRule.cs b/RateLimiter/Core/Rules/FixedWindowRule.cs
new file mode 100644
index 00000000..24d51169
--- /dev/null
+++ b/RateLimiter/Core/Rules/FixedWindowRule.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Concurrent;
+using Microsoft.AspNetCore.Routing;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RateLimiter.Core.Rules
+{
+ public class FixedWindowRule : IRateLimitRule
+ {
+ private readonly int _maxRequests;
+ private readonly TimeSpan _windowSize;
+ private readonly ConcurrentDictionary> _requestTimestamps = new();
+
+ public FixedWindowRule(int maxRequests, TimeSpan windowSize)
+ {
+ _maxRequests = maxRequests;
+ _windowSize = windowSize;
+ }
+
+ public bool IsAllowed(string clientToken, string resourceId)
+ {
+ string key = $"{clientToken}:{resourceId}";
+ var now = DateTime.UtcNow;
+
+ // get or create timestamp list
+ var timestamps = _requestTimestamps.GetOrAdd(key, _ =>
+ {
+ return new List();
+ });
+
+ // remove expired timestamps
+ lock (timestamps)
+ {
+ timestamps.RemoveAll(time => now - time > _windowSize);
+
+ // check if under limit
+ if (timestamps.Count < _maxRequests)
+ {
+ timestamps.Add(now);
+ return true;
+ }
+
+ return false;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Core/Rules/IRateLimitRule.cs b/RateLimiter/Core/Rules/IRateLimitRule.cs
new file mode 100644
index 00000000..4aa16e0e
--- /dev/null
+++ b/RateLimiter/Core/Rules/IRateLimitRule.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Concurrent;
+using Microsoft.AspNetCore.Routing;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RateLimiter.Core.Rules
+{
+ public interface IRateLimitRule
+ {
+ bool IsAllowed(string clientToken, string resourceId);
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Core/Rules/RegionBasedRule.cs b/RateLimiter/Core/Rules/RegionBasedRule.cs
new file mode 100644
index 00000000..34d637c2
--- /dev/null
+++ b/RateLimiter/Core/Rules/RegionBasedRule.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Concurrent;
+using Microsoft.AspNetCore.Routing;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RateLimiter.Core.Rules
+{
+ public class RegionBasedRule : IRateLimitRule
+ {
+ private readonly Dictionary _regionRules;
+ private readonly Func _regionExtractor;
+ private readonly IRateLimitRule _defaultRule;
+
+ public RegionBasedRule(Dictionary regionRules, Func regionExtractor, IRateLimitRule defaultRule = null)
+ {
+ _regionRules = regionRules;
+ _regionExtractor = regionExtractor;
+ _defaultRule = defaultRule;
+ }
+
+ public bool IsAllowed(string clientToken, string resourceId)
+ {
+ // get region
+ var region = _regionExtractor(clientToken);
+
+ // get the rule for this region else use default
+ if (_regionRules.TryGetValue(region, out var rule))
+ {
+ bool result = rule.IsAllowed(clientToken, resourceId);
+ return result;
+ }
+
+ // if no rule for region and no default, allow request
+ if (_defaultRule != null)
+ {
+ return _defaultRule.IsAllowed(clientToken, resourceId);
+ }
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Core/Rules/TokenBucketRule.cs b/RateLimiter/Core/Rules/TokenBucketRule.cs
new file mode 100644
index 00000000..08269875
--- /dev/null
+++ b/RateLimiter/Core/Rules/TokenBucketRule.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Concurrent;
+using Microsoft.AspNetCore.Routing;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RateLimiter.Core.Rules
+{
+ public class TokenBucketRule : IRateLimitRule
+ {
+ private readonly int _capacity;
+ private readonly double _refillRate; // tokens per second
+ private readonly ConcurrentDictionary _buckets = new();
+
+ private class BucketState
+ {
+ public double Tokens { get; set; }
+ public DateTime LastRefillTime { get; set; }
+ }
+
+ public TokenBucketRule(int capacity, double refillRate)
+ {
+ _capacity = capacity;
+ _refillRate = refillRate;
+ }
+
+ public bool IsAllowed(string clientToken, string resourceId)
+ {
+ // create a unique key combining client & resource
+ string key = $"{clientToken}:{resourceId}";
+
+ // get or create bucket for this client/resource
+ var bucket = _buckets.GetOrAdd(key, val => new BucketState
+ {
+ Tokens = _capacity,
+ LastRefillTime = DateTime.UtcNow
+ });
+
+ // refill tokens based on time elapsed
+ RefillTokens(bucket);
+
+ // check if we have tokens to consume
+ if (bucket.Tokens >= 1)
+ {
+ bucket.Tokens -= 1;
+ return true;
+ }
+
+ return false;
+ }
+
+ private void RefillTokens(BucketState bucket)
+ {
+ var now = DateTime.UtcNow;
+ var elapsed = (now - bucket.LastRefillTime).TotalSeconds;
+ if (elapsed > 0)
+ {
+ // add tokens based on elapsed time
+ var tokensToAdd = elapsed * _refillRate;
+
+ // cap at max capacity
+ bucket.Tokens = Math.Min(_capacity, bucket.Tokens + tokensToAdd);
+ bucket.LastRefillTime = now;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Examples/RateLimiterExamples.cs b/RateLimiter/Examples/RateLimiterExamples.cs
new file mode 100644
index 00000000..dd5729a9
--- /dev/null
+++ b/RateLimiter/Examples/RateLimiterExamples.cs
@@ -0,0 +1,27 @@
+using System;
+using RateLimiter.Core;
+using RateLimiter.Core.Rules;
+using RateLimiter.Builders;
+
+namespace RateLimiter.Examples
+{
+ public static class RateLimiterExamples
+ {
+ public static Core.RateLimiter CreateExampleRateLimiter()
+ {
+ var rateLimiter = new Core.RateLimiter();
+
+ // example configurations
+ rateLimiter.ConfigureResource("/api/test", RuleBuilder.CreateTokenBucketRule(3, 3/60.0));
+ rateLimiter.ConfigureResource("/api/orders", RuleBuilder.CreateFixedWindowRule(10, TimeSpan.FromMinutes(1)));
+ rateLimiter.ConfigureResource("/api/orders/item", RuleBuilder.CreateRegionRules());
+
+ // composite rule examples
+ var rule1 = new TokenBucketRule(1000, 1000/3600.0);
+ var rule2 = new FixedWindowRule(5, TimeSpan.FromSeconds(10));
+ rateLimiter.ConfigureResource("/api/users", new CompositeAndRule(rule1, rule2));
+
+ return rateLimiter;
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Middleware/RateLimitingMiddleware.cs b/RateLimiter/Middleware/RateLimitingMiddleware.cs
new file mode 100644
index 00000000..6ab151a3
--- /dev/null
+++ b/RateLimiter/Middleware/RateLimitingMiddleware.cs
@@ -0,0 +1,62 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using RateLimiter.Core;
+using System.Collections.Generic;
+using System;
+
+namespace RateLimiter.Middleware
+{
+ public class RateLimitingMiddleware
+ {
+ private readonly RequestDelegate _next;
+ private readonly Core.RateLimiter _rateLimiter;
+
+ public RateLimitingMiddleware(RequestDelegate next, Core.RateLimiter rateLimiter)
+ {
+ _next = next;
+ _rateLimiter = rateLimiter;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ // get client token
+ string clientToken = GetClientToken(context);
+
+ // get resource identifier
+ string resourceId = context.Request.Path;
+
+ // check if request is allowed
+ if (_rateLimiter.IsRequestAllowed(clientToken, resourceId))
+ {
+ // continue to next middleware
+ await _next(context);
+ }
+ else
+ {
+ // request is rate limited
+ context.Response.StatusCode = 429; // Too Many Requests!
+ context.Response.Headers.Append("Retry-After", "60"); // suggest retry after 60 seconds
+ await context.Response.WriteAsync("Rate limit exceeded..please go away");
+ }
+ }
+
+ private string GetClientToken(HttpContext context)
+ {
+ // check for a region header
+ string region = "US"; // default
+ if (context.Request.Headers.TryGetValue("X-Region", out var regionHeader))
+ {
+ region = regionHeader;
+ }
+
+ // get from API key header
+ if (context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
+ {
+ return $"{region}-{apiKey}";
+ }
+
+ // or use IP address
+ return $"{region}-{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs
new file mode 100644
index 00000000..43794ff8
--- /dev/null
+++ b/RateLimiter/Program.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace RateLimiter
+{
+ public class Program
+ {
+ public static void Main(string[] args)
+ {
+ using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole());
+ ILogger logger = factory.CreateLogger();
+ logger.LogInformation("Hi! Starting up Rate-Limiter");
+
+ CreateHostBuilder(args).Build().Run();
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder =>
+ {
+ webBuilder.UseStartup();
+ });
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Properties/launchSettings.json b/RateLimiter/Properties/launchSettings.json
new file mode 100644
index 00000000..241feb74
--- /dev/null
+++ b/RateLimiter/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "RateLimiter": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "launchUrl": "api/test",
+ "applicationUrl": "https://localhost:5001;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj
index 19962f52..fa23dc5e 100644
--- a/RateLimiter/RateLimiter.csproj
+++ b/RateLimiter/RateLimiter.csproj
@@ -1,7 +1,19 @@
-
+
- net6.0
+ net8.0
latest
enable
+ RateLimiter
+ Exe
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/RateLimiter/Startup.cs b/RateLimiter/Startup.cs
new file mode 100644
index 00000000..3206715b
--- /dev/null
+++ b/RateLimiter/Startup.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using RateLimiter.Core;
+using RateLimiter.Core.Rules;
+using RateLimiter.Middleware;
+using RateLimiter.Examples;
+using Microsoft.AspNetCore.Hosting;
+
+namespace RateLimiter
+{
+ public class Startup
+ {
+ public Startup(IConfiguration configuration)
+ {
+ Configuration = configuration;
+ }
+
+ public IConfiguration Configuration { get; }
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddControllers();
+
+ // register rate limiter
+ services.AddSingleton(RateLimiterExamples.CreateExampleRateLimiter());
+ }
+
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ // add middleware
+ app.UseMiddleware();
+
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/appsettings.Development.json b/RateLimiter/appsettings.Development.json
new file mode 100644
index 00000000..ff66ba6b
--- /dev/null
+++ b/RateLimiter/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/RateLimiter/appsettings.json b/RateLimiter/appsettings.json
new file mode 100644
index 00000000..4d566948
--- /dev/null
+++ b/RateLimiter/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}