Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,47 @@
**Rate-limiting pattern**
**Implementation - Brandon Choi**

I utilized the builder pattern to provide a clear and straightforward way to construct rate limiting rules. Composite rules were implemented to support both logical AND and OR operations, allowing for flexible combinations of rules. Additionally, I employed a simple decorator pattern to seamlessly extend rule evaluation with additional logic.

Here are usage examples:

1. Combining rules using AND operation:
```
var maxRequestsRule = new MaxRequestsPerTimeSpanRule(5, TimeSpan.FromSeconds(10));
var minTimeRule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(2));

var builder = new RateLimitRuleBuilder();
var combinedRule = builder
.Add(maxRequestsRule)
.Add(minTimeRule)
.Build();
```

2. Combining rules using OR operation:
```
var maxRequestsRule = new MaxRequestsPerTimeSpanRule(1, TimeSpan.FromSeconds(10));
var minTimeRule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(5));

var builder = new RateLimitRuleBuilder();
var combinedRule = builder
.Or(new List<IRateLimitRule> { maxRequestsRule, minTimeRule })
.Build();
```

3. Region specific rules:
```
var usRule = new MaxRequestsPerTimeSpanRule(5, TimeSpan.FromSeconds(10));
var euRule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(5));

var regionBuilder = new RateLimitRuleBuilder();
var regionCombinedRule = regionBuilder
.AddForRegion(usRule, Region.US)
.AddForRegion(euRule, Region.EU)
.Build();
```

#

**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.
Expand Down
112 changes: 107 additions & 5 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,115 @@
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Threading;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;
using RateLimiter.Interfaces;
using RateLimiter.Builder;

namespace RateLimiter.Tests;

/// <summary>
/// Collection of tests to demonstrate RateLimiter project usage.
/// Given more time, I would provide more detailed tests of each class and aim for 100% code coverage.
/// </summary>
[TestFixture]
public class RateLimiterTest
public class RateLimiterTests
{
[Test]
public void Example()
public void RateLimitRuleBuilder_CombinesTwoPassingRulesWithAnd_ReturnsAllowed()
{
Assert.That(true, Is.True);
// Arrange
var rule1 = new MaxRequestsPerTimeSpanRule(5, TimeSpan.FromSeconds(10));
var rule2 = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(2));

// Act
var combinedRule = new RateLimitRuleBuilder()
.Add(rule1)
.Add(rule2)
.Build();

var context = new RateLimitContext("client-123", "api/resource", Region.US);
var result = combinedRule.Evaluate(context);

// Assert
Assert.IsTrue(result.Allowed);
Assert.IsEmpty(result.RejectedReasons);
}

[Test]
public void RateLimitRuleBuilder_CombinesTwoRulesWithOr_ReturnsAllowed()
{
// Arrange
var rule1 = new MaxRequestsPerTimeSpanRule(1, TimeSpan.FromSeconds(10));
var rule2 = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(5));
var builder = new RateLimitRuleBuilder();

// Act, Assert
var combinedRule = builder
.Or(new List<IRateLimitRule> { rule1, rule2 })
.Build();
var context = new RateLimitContext("client-123", "api/resource", Region.US);

var response1 = combinedRule.Evaluate(context);
Assert.IsTrue(response1.Allowed);

var response2 = combinedRule.Evaluate(context);
Assert.IsFalse(response2.Allowed);
Thread.Sleep(6000);

var response3 = combinedRule.Evaluate(context);
Assert.IsTrue(response3.Allowed);
}

[Test]
public void RateLimitRuleBuilder_RegionMatchesAndEvaluatesRule_ReturnsAllowed()
{
// Arrange
var rule = new MaxRequestsPerTimeSpanRule(1, TimeSpan.FromSeconds(10));
var builder = new RateLimitRuleBuilder();
var region = Region.US;

// Act
var combinedRule = builder
.AddForRegion(rule, region)
.Build();
var context = new RateLimitContext("client-123", "api/resource", region);

// Assert
var response = combinedRule.Evaluate(context);
Assert.IsTrue(response.Allowed);
}

[Test]
public void RateLimitRuleBuilder_RegionBasedRules_ReturnsAllowedForUSAndNotAllowedForEU()
{
// Arrange
var usRule = new MaxRequestsPerTimeSpanRule(5, TimeSpan.FromSeconds(10));
var euRule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(5));
var builder = new RateLimitRuleBuilder();

// Act
var combinedRule = builder
.AddForRegion(usRule, Region.US)
.AddForRegion(euRule, Region.EU)
.Build();

// US-based context
var usContext = new RateLimitContext("us-client-123", "api/resource", Region.US);
for (int i = 0; i < 5; i++)
{
var response = combinedRule.Evaluate(usContext);
Assert.IsTrue(response.Allowed);
}

// EU-based context
var euContext = new RateLimitContext("eu-client-456", "api/resource", Region.EU);
var response1 = combinedRule.Evaluate(euContext);
Assert.IsTrue(response1.Allowed);

var response2 = combinedRule.Evaluate(euContext);
Assert.IsFalse(response2.Allowed);
Assert.That(response2.RejectedReasons, Has.Exactly(1).EqualTo(nameof(MinimumTimeBetweenRequestsRule)));
}
}
}
98 changes: 98 additions & 0 deletions RateLimiter.Tests/Rules/MaxRequestsPerTimeSpanRuleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System;
using System.Threading;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;

namespace RateLimiter.Tests.Rules
{
[TestFixture]
public class MaxRequestsPerTimeSpanRuleTests
{
[Test]
public void Evaluate_LessThanMaxRequests_ReturnsAllowed()
{
// Arrange
var rule = new MaxRequestsPerTimeSpanRule(5, TimeSpan.FromSeconds(10));
var context = new RateLimitContext("client-123", "api/resource", Region.US);

// Act and Assert
for (int i = 0; i < 5; i++)
{
var response = rule.Evaluate(context);
Assert.IsTrue(response.Allowed);
}
}

[Test]
public void MaxRequestsPerTimeSpanRule_MaxRequestsExceeded_ReturnsNotAllowed()
{
// Arrange
var rule = new MaxRequestsPerTimeSpanRule(5, TimeSpan.FromSeconds(10));
var context = new RateLimitContext("client-123", "api/resource", Region.US);

// Act
for (int i = 0; i < 5; i++)
{
rule.Evaluate(context);
}
var response = rule.Evaluate(context);

// Assert
Assert.IsFalse(response.Allowed);
Assert.That(response.RejectedReasons, Has.Exactly(1).EqualTo(nameof(MaxRequestsPerTimeSpanRule)));
}

[Test]
public void MaxRequestsPerTimeSpan_TimeSpanExceeded_ReturnsAllowed()
{
// Arrange
var rule = new MaxRequestsPerTimeSpanRule(5, TimeSpan.FromSeconds(2));
var context = new RateLimitContext("client-123", "api/resource", Region.US);

// Act
for (int i = 0; i < 5; i++)
{
rule.Evaluate(context);
}
Thread.Sleep(2100);
var response = rule.Evaluate(context);

// Assert
Assert.IsTrue(response.Allowed);
}

[Test]
public void MinimumTimeBetweenRequestsRule_MinimumTimeNotPassed_ReturnsNotAllowed()
{
// Arrange
var rule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(2));
var context = new RateLimitContext("client-123", "api/resource", Region.US);

// Act
var response1 = rule.Evaluate(context);
var response2 = rule.Evaluate(context);

// Assert
Assert.IsTrue(response1.Allowed);
Assert.IsFalse(response2.Allowed);
Assert.That(response2.RejectedReasons, Has.Exactly(1).EqualTo(nameof(MinimumTimeBetweenRequestsRule)));
}

[Test]
public void MinimumTimeBetweenRequestsRule_MinimumTimePassed_ReturnsAllowed()
{
// Arrange
var rule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(2));
var context = new RateLimitContext("client-123", "api/resource", Region.US);

// Act
rule.Evaluate(context);
Thread.Sleep(2100);
var response = rule.Evaluate(context);

// Assert
Assert.IsTrue(response.Allowed);
}
}
}
45 changes: 45 additions & 0 deletions RateLimiter.Tests/Rules/MinimumTimeBetweenRequestsRuleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Threading;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;

namespace RateLimiter.Tests.Rules
{
[TestFixture]
public class MinimumTimeBetweenRequestsRuleTests
{
[Test]
public void Evaluate_MinimumTimeNotPassed_ReturnsNotAllowed()
{
// Arrange
var rule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(2));
var context = new RateLimitContext("client-123", "api/resource", Region.US);

// Act
var response1 = rule.Evaluate(context);
var response2 = rule.Evaluate(context);

// Assert
Assert.IsTrue(response1.Allowed);
Assert.IsFalse(response2.Allowed);
Assert.That(response2.RejectedReasons, Has.Exactly(1).EqualTo(nameof(MinimumTimeBetweenRequestsRule)));
}

[Test]
public void Evaluate_MinimumTimePassed_ReturnsAllowed()
{
// Arrange
var rule = new MinimumTimeBetweenRequestsRule(TimeSpan.FromSeconds(2));
var context = new RateLimitContext("client-123", "api/resource", Region.US);

// Act
rule.Evaluate(context);
Thread.Sleep(2100);
var response = rule.Evaluate(context);

// Assert
Assert.IsTrue(response.Allowed);
}
}
}
79 changes: 79 additions & 0 deletions RateLimiter/Builder/RateLimitRuleBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using RateLimiter.Interfaces;
using RateLimiter.Rules;
using RateLimiter.Models;

namespace RateLimiter.Builder;

/// <summary>
/// Builder to compile individual and/or groups of rate limit rules.
/// By default, will evaluate multiple rules with logical AND.
/// </summary>
public class RateLimitRuleBuilder
{
private readonly List<IRateLimitRule> _rateLimitRules = new List<IRateLimitRule>();

/// <summary>
/// Adds rule to builder.
/// </summary>
/// <param name="rule"><see cref="IRateLimitRule"/></param>
/// <returns><see cref="RateLimitRuleBuilder"/></returns>
public RateLimitRuleBuilder Add(IRateLimitRule rule)
{
_rateLimitRules.Add(rule);
return this;
}

/// <summary>
/// Adds a collection of rules that will be evaluated with logical AND.
/// </summary>
/// <param name="rateLimitRules">Collection of rate limit rules.</param>
/// <returns><see cref="RateLimitRuleBuilder"/></returns>
public RateLimitRuleBuilder Add(IEnumerable<IRateLimitRule> rateLimitRules)
{
_rateLimitRules.Add(new AndCompositeRule(rateLimitRules));
return this;
}

/// <summary>
/// Adds a collection of rules that will be evaluated with logical OR.
/// </summary>
/// <param name="rateLimitRules"></param>
/// <returns><see cref="RateLimitRuleBuilder"/></returns>
public RateLimitRuleBuilder Or(IEnumerable<IRateLimitRule> rateLimitRules)
{
_rateLimitRules.Add(new OrCompositeRule(rateLimitRules));
return this;
}

/// <summary>
/// Adds a rule that should only apply for a specified region.
/// </summary>
/// <param name="rule"><see cref="IRateLimitRule"/></param>
/// <param name="region"><see cref="Region"/></param>
/// <returns>The current builder instance for fluent chaining.</returns>
public RateLimitRuleBuilder AddForRegion(IRateLimitRule rule, Region region)
{
_rateLimitRules.Add(new RegionBasedRuleDecorator(rule, region));
return this;
}

/// <summary>
/// Compiles the list of rules into a single rule combined with logical AND.
/// </summary>
/// <returns><see cref="IRateLimitRule"/></returns>
public IRateLimitRule Build()
{
if (_rateLimitRules.Count == 0)
{
throw new InvalidOperationException("No rate limit rules have been added.");
}
else if (_rateLimitRules.Count == 1)
{
return _rateLimitRules.First();
}
return new AndCompositeRule(_rateLimitRules);
}
}
Loading