Skip to content
Open
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
2 changes: 2 additions & 0 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
</ItemGroup>
Expand Down
106 changes: 100 additions & 6 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,107 @@
using NUnit.Framework;
using System;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using NUnit.Framework;
using RateLimiter.Model;

namespace RateLimiter.Tests;

[TestFixture]
public class RateLimiterTest
{
[Test]
public void Example()
{
Assert.That(true, Is.True);
}
private IRateLimiterFactory _rateLimiterFactory;
private Mock<IMemoryCache> _memoryCacheMock;

[SetUp]
public void SetUp()
{
_memoryCacheMock = new Mock<IMemoryCache>();
_rateLimiterFactory = new RateLimiterFactory(_memoryCacheMock.Object);
}

[Test]
public void RateLimitResource2_DoNot_Allowed()
{
var clientData = new ClientModel()
{
ClientId = "123"
};

var cacheKey = $"rateLimiter-lastCall-resource2-{clientData.ClientId}";
var oneMinuteAgo = DateTime.UtcNow.AddMinutes(-1);

object outValue = oneMinuteAgo;

_memoryCacheMock
.Setup(x => x.TryGetValue(cacheKey, out outValue))
.Returns(true);

_memoryCacheMock
.Setup(x => x.CreateEntry(It.IsAny<object>()))
.Returns(Mock.Of<ICacheEntry>);

var rateLimiterResource2 = _rateLimiterFactory.CreateRateLimiter("resource2", clientData);
var isAllowedRequest = rateLimiterResource2.IsRequestAllowed();

Assert.That(isAllowedRequest, Is.False);
}

[TestCase(2, true)]
[TestCase(5, false)]
public void RateLimitResource1_Allowed(int requestCount, bool result)
{
var clientData = new ClientModel()
{
ClientId = "123"
};

var cacheKey = $"rateLimiter-count-resource1-123";

object outValue = requestCount;

_memoryCacheMock
.Setup(x => x.TryGetValue(cacheKey, out outValue))
.Returns(true);

_memoryCacheMock
.Setup(x => x.CreateEntry(It.IsAny<object>()))
.Returns(Mock.Of<ICacheEntry>);

var rateLimiterResource3 = _rateLimiterFactory.CreateRateLimiter("resource1", clientData);
var isAllowedRequest = rateLimiterResource3.IsRequestAllowed();

Assert.AreEqual(isAllowedRequest, result);
}

[Test]
public void RateLimitResource3_USBased_Allowed()
{
var clientData = new ClientModel()
{
ClientId = "123",
Region = "US"
};

_memoryCacheMock
.Setup(x => x.CreateEntry(It.IsAny<object>()))
.Returns(Mock.Of<ICacheEntry>);

var rateLimiterResource3 = _rateLimiterFactory.CreateRateLimiter("resource3", clientData);
var isAllowedRequest = rateLimiterResource3.IsRequestAllowed();

Assert.That(isAllowedRequest, Is.True);
}

[Test]
public void RateLimitResource_Exception()
{
var clientData = new ClientModel();

var ex = Assert.Throws<ArgumentException>(() =>
{
_rateLimiterFactory.CreateRateLimiter("unknownResource", clientData);
});

Assert.That(ex.Message, Is.EqualTo("Cannot resolve resource mapping with url: unknownResource"));
}
}
10 changes: 10 additions & 0 deletions RateLimiter/IRateLimiterFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using RateLimiter.Model;
using RateLimiter.Strategies;

namespace RateLimiter
{
public interface IRateLimiterFactory
{
IRateLimitService CreateRateLimiter(string resourceUrl, ClientModel clientData);
}
}
12 changes: 12 additions & 0 deletions RateLimiter/Model/ClientModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace RateLimiter.Model
{
/// <summary>
/// Data from access token
/// </summary>
public class ClientModel
{
public string ClientId { get; set; }
public string Region { get; set; }
public string SomeOtherData { get; set; }
}
}
3 changes: 3 additions & 0 deletions RateLimiter/RateLimiter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.1" />
</ItemGroup>
</Project>
37 changes: 37 additions & 0 deletions RateLimiter/RateLimiterFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using Microsoft.Extensions.Caching.Memory;
using RateLimiter.Model;
using RateLimiter.Strategies;

namespace RateLimiter
{
public class RateLimiterFactory : IRateLimiterFactory
{
private IMemoryCache _memoryCache;

public RateLimiterFactory(IMemoryCache memoryCache)
{
_memoryCache = memoryCache;
}

public IRateLimitService CreateRateLimiter(string resourceUrl, ClientModel clientData)
{
if (string.IsNullOrEmpty(resourceUrl))
{
throw new ArgumentException($"{nameof(resourceUrl)} cannot be null or empty");
}

switch (resourceUrl)
{
case "resource1":
return new RateLimitResource1Service(_memoryCache, clientData);
case "resource2":
return new RateLimitResource2Service(_memoryCache, clientData);
case "resource3":
return new RateLimitResource3Service(_memoryCache, clientData);
default:
throw new ArgumentException($"Cannot resolve resource mapping with url: {resourceUrl}");
}
}
}
}
10 changes: 10 additions & 0 deletions RateLimiter/Rules/IRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Microsoft.Extensions.Caching.Memory;
using RateLimiter.Model;

namespace RateLimiter.Rules
{
public interface IRateLimitRule
{
bool IsRequestAllowed(ClientModel clientData, string resourceUrl, IMemoryCache memoryCache);
}
}
17 changes: 17 additions & 0 deletions RateLimiter/Rules/IRateLimitRuleBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;

namespace RateLimiter.Rules
{
public interface IRateLimitRuleBuilder
{
IRateLimitRuleBuilder Build();

IRateLimitRuleBuilder WithTimeSinceLastCallRule(TimeSpan minInterval);

IRateLimitRuleBuilder WithRequestCountRule(int maxRequests, TimeSpan timeSpan);

IRateLimitRuleBuilder ApplyRule(bool condition, IRateLimitRule rule);

bool IsAllowed();
}
}
69 changes: 69 additions & 0 deletions RateLimiter/Rules/RateLimitRuleBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
using RateLimiter.Model;

namespace RateLimiter.Rules
{
public class RateLimitRuleBuilder : IRateLimitRuleBuilder
{
private bool _allowRequest = true;
private readonly IMemoryCache _memoryCache;
private List<IRateLimitRule> _rules = new List<IRateLimitRule>();
private ClientModel _clientData;
private string _resourceName;

public RateLimitRuleBuilder(IMemoryCache memoryCache, ClientModel clientData, string resourceName)
{
_memoryCache = memoryCache;
_clientData = clientData;
_resourceName = resourceName;
}

public IRateLimitRuleBuilder Build()
{
foreach (var rule in _rules)
{
if (!_allowRequest)
{
break;
}

_allowRequest = rule.IsRequestAllowed(_clientData, _resourceName, _memoryCache);
}

return this;
}

public IRateLimitRuleBuilder WithRequestCountRule(int maxRequests, TimeSpan timeSpan)
{
var rule = new RequestCountRule(maxRequests, timeSpan);
_rules.Add(rule);

return this;
}

public IRateLimitRuleBuilder WithTimeSinceLastCallRule(TimeSpan minInterval)
{
var rule = new TimeSinceLastCallRule(minInterval);
_rules.Add(rule);

return this;
}

public IRateLimitRuleBuilder ApplyRule(bool condition, IRateLimitRule rule)
{
if (condition)
{
_rules.Add(rule);
}

return this;
}

public bool IsAllowed()
{
return _allowRequest;
}
}
}
42 changes: 42 additions & 0 deletions RateLimiter/Rules/RequestCountRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using Microsoft.Extensions.Caching.Memory;
using RateLimiter.Model;

namespace RateLimiter.Rules
{
/// <summary>
/// X requests per timespan
/// </summary>
public class RequestCountRule : IRateLimitRule
{
private readonly int _maxRequests;
private readonly TimeSpan _timeSpan;

public RequestCountRule(int maxRequests, TimeSpan timeSpan)
{
_maxRequests = maxRequests;
_timeSpan = timeSpan;
}

public bool IsRequestAllowed(ClientModel clientData, string resourceName, IMemoryCache memoryCache)
{
string cacheKey = $"rateLimiter-count-{resourceName}-{clientData.ClientId}";

if (!memoryCache.TryGetValue(cacheKey, out int requestCount))
{
memoryCache.Set(cacheKey, 1, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = _timeSpan });

return true;
}

if (requestCount >= _maxRequests)
{
return false;
}

memoryCache.Set(cacheKey, requestCount + 1, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = _timeSpan });

return true;
}
}
}
40 changes: 40 additions & 0 deletions RateLimiter/Rules/TimeSinceLastCallRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using Microsoft.Extensions.Caching.Memory;
using RateLimiter.Model;

namespace RateLimiter.Rules
{
/// <summary>
/// A certain timespan has passed since the last call.
/// </summary>
public class TimeSinceLastCallRule : IRateLimitRule
{
private readonly TimeSpan _minInterval;

public TimeSinceLastCallRule(TimeSpan minInterval)
{
_minInterval = minInterval;
}

public bool IsRequestAllowed(ClientModel clientData, string resourceName, IMemoryCache memoryCache)
{
string cacheKey = $"rateLimiter-lastCall-{resourceName}-{clientData.ClientId}";

if (!memoryCache.TryGetValue(cacheKey, out DateTime lastRequestTime))
{
memoryCache.Set(cacheKey, DateTime.UtcNow);

return true;
}

if (DateTime.UtcNow - lastRequestTime < _minInterval)
{
return false;
}

memoryCache.Set(cacheKey, DateTime.UtcNow);

return true;
}
}
}
7 changes: 7 additions & 0 deletions RateLimiter/Strategies/IRateLimitService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RateLimiter.Strategies
{
public interface IRateLimitService
{
public bool IsRequestAllowed();
}
}
Loading