From 01df551f9ed10f7f62254d903f0aca675e0f0305 Mon Sep 17 00:00:00 2001
From: Andrei <7522793+Zyertdox@users.noreply.github.com>
Date: Mon, 10 Feb 2025 03:54:41 +0100
Subject: [PATCH] Implement basic functionality
---
.idea/.idea.RateLimiter/.idea/.gitignore | 13 ++++
.idea/.idea.RateLimiter/.idea/.name | 1 +
.idea/.idea.RateLimiter/.idea/encodings.xml | 4 ++
.idea/.idea.RateLimiter/.idea/indexLayout.xml | 8 +++
.../DelayRateLimiterRuleTests.cs | 58 +++++++++++++++++
RateLimiter.Tests/Moq.cs | 35 +++++++++++
RateLimiter.Tests/RateLimiter.Tests.csproj | 1 +
RateLimiter.Tests/RateLimiterTest.cs | 34 ++++++++--
.../TimeWindowRateLimiterRuleTests.cs | 62 +++++++++++++++++++
RateLimiter.sln.DotSettings.user | 8 +++
RateLimiter/Abstractions/IRateLimiter.cs | 6 ++
RateLimiter/Abstractions/IRateLimiterRule.cs | 9 +++
RateLimiter/Exceptions/RateLimitException.cs | 8 +++
.../Implementations/DelayRateLimiterRule.cs | 17 +++++
RateLimiter/Implementations/RateLimiter.cs | 39 ++++++++++++
.../SpecificRateLimiterRule.cs | 22 +++++++
.../TimeWindowRateLimiterRule.cs | 17 +++++
.../Infrastructure/IRequestsRepository.cs | 10 +++
RateLimiter/RateLimiter.csproj | 3 +
19 files changed, 349 insertions(+), 6 deletions(-)
create mode 100644 .idea/.idea.RateLimiter/.idea/.gitignore
create mode 100644 .idea/.idea.RateLimiter/.idea/.name
create mode 100644 .idea/.idea.RateLimiter/.idea/encodings.xml
create mode 100644 .idea/.idea.RateLimiter/.idea/indexLayout.xml
create mode 100644 RateLimiter.Tests/DelayRateLimiterRuleTests.cs
create mode 100644 RateLimiter.Tests/Moq.cs
create mode 100644 RateLimiter.Tests/TimeWindowRateLimiterRuleTests.cs
create mode 100644 RateLimiter.sln.DotSettings.user
create mode 100644 RateLimiter/Abstractions/IRateLimiter.cs
create mode 100644 RateLimiter/Abstractions/IRateLimiterRule.cs
create mode 100644 RateLimiter/Exceptions/RateLimitException.cs
create mode 100644 RateLimiter/Implementations/DelayRateLimiterRule.cs
create mode 100644 RateLimiter/Implementations/RateLimiter.cs
create mode 100644 RateLimiter/Implementations/SpecificRateLimiterRule.cs
create mode 100644 RateLimiter/Implementations/TimeWindowRateLimiterRule.cs
create mode 100644 RateLimiter/Infrastructure/IRequestsRepository.cs
diff --git a/.idea/.idea.RateLimiter/.idea/.gitignore b/.idea/.idea.RateLimiter/.idea/.gitignore
new file mode 100644
index 00000000..6c4892a3
--- /dev/null
+++ b/.idea/.idea.RateLimiter/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/modules.xml
+/.idea.RateLimiter.iml
+/contentModel.xml
+/projectSettingsUpdater.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/.idea.RateLimiter/.idea/.name b/.idea/.idea.RateLimiter/.idea/.name
new file mode 100644
index 00000000..fdddf83b
--- /dev/null
+++ b/.idea/.idea.RateLimiter/.idea/.name
@@ -0,0 +1 @@
+RateLimiter
\ No newline at end of file
diff --git a/.idea/.idea.RateLimiter/.idea/encodings.xml b/.idea/.idea.RateLimiter/.idea/encodings.xml
new file mode 100644
index 00000000..df87cf95
--- /dev/null
+++ b/.idea/.idea.RateLimiter/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.RateLimiter/.idea/indexLayout.xml b/.idea/.idea.RateLimiter/.idea/indexLayout.xml
new file mode 100644
index 00000000..7b08163c
--- /dev/null
+++ b/.idea/.idea.RateLimiter/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/RateLimiter.Tests/DelayRateLimiterRuleTests.cs b/RateLimiter.Tests/DelayRateLimiterRuleTests.cs
new file mode 100644
index 00000000..9ccbf7f8
--- /dev/null
+++ b/RateLimiter.Tests/DelayRateLimiterRuleTests.cs
@@ -0,0 +1,58 @@
+using System;
+using NUnit.Framework;
+using RateLimiter.Exceptions;
+using RateLimiter.Implementations;
+
+namespace RateLimiter.Tests;
+
+[TestFixture]
+public class DelayRateLimiterRuleTests
+{
+ [Test]
+ public void Validate_EnoughTime_Ok()
+ {
+ // Arrange
+ const int delay = 10;
+ var token = Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay + delay / 2, delay * 2);
+ var limiter = new DelayRateLimiterRule(TimeSpan.FromSeconds(delay));
+ // Act, Assert
+ Assert.DoesNotThrow(() => limiter.Validate(token, previousRequests));
+ }
+
+ [Test]
+ public void Validate_NotEnoughTime_ThrowsException()
+ {
+ // Arrange
+ const int delay = 10;
+ var token = Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay / 2);
+ var limiter = new DelayRateLimiterRule(TimeSpan.FromSeconds(delay));
+ // Act, Assert
+ Assert.Throws(() => limiter.Validate(token, previousRequests));
+ }
+
+ [Test]
+ public void Validate_NotEnoughTimeWithSelector_ThrowsException()
+ {
+ // Arrange
+ const int delay = 10;
+ var token = "US-" + Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay / 2);
+ var limiter = new DelayRateLimiterRule(TimeSpan.FromSeconds(delay), Moq.PrefixSelector("US-"));
+ // Act, Assert
+ Assert.Throws(() => limiter.Validate(token, previousRequests));
+ }
+
+ [Test]
+ public void Validate_NotApplicable_Ok()
+ {
+ // Arrange
+ const int delay = 10;
+ var token = "EU-" + Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay / 2);
+ var limiter = new DelayRateLimiterRule(TimeSpan.FromSeconds(delay), Moq.PrefixSelector("US-"));
+ // Act, Assert
+ Assert.DoesNotThrow(() => limiter.Validate(token, previousRequests));
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter.Tests/Moq.cs b/RateLimiter.Tests/Moq.cs
new file mode 100644
index 00000000..5d61ec56
--- /dev/null
+++ b/RateLimiter.Tests/Moq.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Moq;
+using RateLimiter.Abstractions;
+using RateLimiter.Exceptions;
+using RateLimiter.Infrastructure;
+
+namespace RateLimiter.Tests;
+
+public static class Moq
+{
+ public static IRateLimiterRule Rule(bool isSuccess)
+ {
+ var mock = new Mock();
+ if (!isSuccess)
+ {
+ mock.Setup(x => x.Validate(It.IsAny(), It.IsAny>()))
+ .Throws(new RateLimitException(string.Empty, string.Empty));
+ }
+ return mock.Object;
+ }
+
+ public static Func PrefixSelector(string prefix) => x => x.StartsWith(prefix);
+
+ public static IRequestsRepository RepositoryFromDelays(params int[] delays)
+ {
+ var mock = new Mock();
+ mock.Setup(x => x.GetPreviousRequests(It.IsAny()))
+ .Returns(RequestsDates(delays));
+ return mock.Object;
+ }
+
+ public static List RequestsDates(params int[] delays) => delays.Select(x => DateTime.UtcNow.AddSeconds(-x)).ToList();
+}
\ No newline at end of file
diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj
index 5cbfc4e8..ef10b84d 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..c5d10091 100644
--- a/RateLimiter.Tests/RateLimiterTest.cs
+++ b/RateLimiter.Tests/RateLimiterTest.cs
@@ -1,13 +1,35 @@
-using NUnit.Framework;
+using System;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using RateLimiter.Exceptions;
namespace RateLimiter.Tests;
[TestFixture]
public class RateLimiterTest
{
- [Test]
- public void Example()
- {
- Assert.That(true, Is.True);
- }
+ [Test]
+ public void LimitRequestsForToken_Ok()
+ {
+ // Arrange
+ var token = Guid.NewGuid().ToString("N");
+ var limiter = new Implementations.RateLimiter([Moq.Rule(true)],
+ Moq.RepositoryFromDelays(),
+ Mock.Of>());
+ // Act, Assert
+ Assert.DoesNotThrow(() => limiter.LimitRequestsForToken(token));
+ }
+
+ [Test]
+ public void LimitRequestsForToken_Fails()
+ {
+ // Arrange
+ var token = Guid.NewGuid().ToString("N");
+ var limiter = new Implementations.RateLimiter([Moq.Rule(false)],
+ Moq.RepositoryFromDelays(),
+ Mock.Of>());
+ // Act, Assert
+ Assert.Throws(() => limiter.LimitRequestsForToken(token));
+ }
}
\ No newline at end of file
diff --git a/RateLimiter.Tests/TimeWindowRateLimiterRuleTests.cs b/RateLimiter.Tests/TimeWindowRateLimiterRuleTests.cs
new file mode 100644
index 00000000..6cd7a585
--- /dev/null
+++ b/RateLimiter.Tests/TimeWindowRateLimiterRuleTests.cs
@@ -0,0 +1,62 @@
+using System;
+using NUnit.Framework;
+using RateLimiter.Exceptions;
+using RateLimiter.Implementations;
+
+namespace RateLimiter.Tests;
+
+[TestFixture]
+public class TimeWindowRateLimiterRuleTests
+{
+ [Test]
+ public void Validate_EnoughTime_Ok()
+ {
+ // Arrange
+ const int delay = 10;
+ const int maxRequests = 3;
+ var token = Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay / 2, delay / 2, delay * 2);
+ var limiter = new TimeWindowRateLimiterRule(TimeSpan.FromSeconds(delay), maxRequests);
+ // Act, Assert
+ Assert.DoesNotThrow(() => limiter.Validate(token, previousRequests));
+ }
+
+ [Test]
+ public void Validate_NotEnoughTime_ThrowsException()
+ {
+ // Arrange
+ const int delay = 10;
+ const int maxRequests = 3;
+ var token = Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay / 2, delay / 2, delay / 2);
+ var limiter = new TimeWindowRateLimiterRule(TimeSpan.FromSeconds(delay), maxRequests);
+ // Act, Assert
+ Assert.Throws(() => limiter.Validate(token, previousRequests));
+ }
+
+ [Test]
+ public void Validate_NotEnoughTimeWithSelector_ThrowsException()
+ {
+ // Arrange
+ const int delay = 10;
+ const int maxRequests = 3;
+ var token = "US-" + Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay / 2, delay / 2, delay / 2);
+ var limiter = new TimeWindowRateLimiterRule(TimeSpan.FromSeconds(delay), maxRequests, Moq.PrefixSelector("US-"));
+ // Act, Assert
+ Assert.Throws(() => limiter.Validate(token, previousRequests));
+ }
+
+ [Test]
+ public void Validate_NotApplicable_Ok()
+ {
+ // Arrange
+ const int delay = 10;
+ const int maxRequests = 3;
+ var token = "EU-" + Guid.NewGuid().ToString("N");
+ var previousRequests = Moq.RequestsDates(delay / 2, delay / 2, delay / 2);
+ var limiter = new TimeWindowRateLimiterRule(TimeSpan.FromSeconds(delay), maxRequests, Moq.PrefixSelector("US-"));
+ // Act, Assert
+ Assert.DoesNotThrow(() => limiter.Validate(token, previousRequests));
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter.sln.DotSettings.user b/RateLimiter.sln.DotSettings.user
new file mode 100644
index 00000000..2e2f34d5
--- /dev/null
+++ b/RateLimiter.sln.DotSettings.user
@@ -0,0 +1,8 @@
+
+ <SessionState ContinuousTestingMode="0" IsActive="True" Name="DelayRateLimiterRuleTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
+ <TestAncestor>
+ <TestId>NUnit3x::C4F9249B-010E-46BE-94B8-DD20D82F1E60::net6.0::RateLimiter.Tests.DelayRateLimiterRuleTests</TestId>
+ <TestId>NUnit3x::C4F9249B-010E-46BE-94B8-DD20D82F1E60::net6.0::RateLimiter.Tests.TimeWindowRateLimiterRuleTests</TestId>
+ <TestId>NUnit3x::C4F9249B-010E-46BE-94B8-DD20D82F1E60::net6.0::RateLimiter.Tests.RateLimiterTest</TestId>
+ </TestAncestor>
+</SessionState>
\ No newline at end of file
diff --git a/RateLimiter/Abstractions/IRateLimiter.cs b/RateLimiter/Abstractions/IRateLimiter.cs
new file mode 100644
index 00000000..95b728dc
--- /dev/null
+++ b/RateLimiter/Abstractions/IRateLimiter.cs
@@ -0,0 +1,6 @@
+namespace RateLimiter.Abstractions;
+
+public interface IRateLimiter
+{
+ public void LimitRequestsForToken(string token);
+}
\ No newline at end of file
diff --git a/RateLimiter/Abstractions/IRateLimiterRule.cs b/RateLimiter/Abstractions/IRateLimiterRule.cs
new file mode 100644
index 00000000..f851a490
--- /dev/null
+++ b/RateLimiter/Abstractions/IRateLimiterRule.cs
@@ -0,0 +1,9 @@
+using System;
+using System.Collections.Generic;
+
+namespace RateLimiter.Abstractions;
+
+public interface IRateLimiterRule
+{
+ void Validate(string token, IReadOnlyCollection previousRequests);
+}
\ No newline at end of file
diff --git a/RateLimiter/Exceptions/RateLimitException.cs b/RateLimiter/Exceptions/RateLimitException.cs
new file mode 100644
index 00000000..9c3b4127
--- /dev/null
+++ b/RateLimiter/Exceptions/RateLimitException.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace RateLimiter.Exceptions;
+
+public class RateLimitException(string message, string ruleType) : Exception(message)
+{
+ public string RuleType => ruleType;
+}
\ No newline at end of file
diff --git a/RateLimiter/Implementations/DelayRateLimiterRule.cs b/RateLimiter/Implementations/DelayRateLimiterRule.cs
new file mode 100644
index 00000000..4d2d964e
--- /dev/null
+++ b/RateLimiter/Implementations/DelayRateLimiterRule.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using RateLimiter.Exceptions;
+
+namespace RateLimiter.Implementations;
+
+public class DelayRateLimiterRule(TimeSpan timeSpan, Func? selector = null) : SpecificRateLimiterRule(selector)
+{
+ protected override void ValidateIfRequired(string token, IReadOnlyCollection previousRequests)
+ {
+ if (previousRequests.Any(x => x + timeSpan >= DateTime.UtcNow))
+ {
+ throw new RateLimitException("Not enough Time since last request", nameof(DelayRateLimiterRule));
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Implementations/RateLimiter.cs b/RateLimiter/Implementations/RateLimiter.cs
new file mode 100644
index 00000000..887d62f8
--- /dev/null
+++ b/RateLimiter/Implementations/RateLimiter.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+using RateLimiter.Abstractions;
+using RateLimiter.Exceptions;
+using RateLimiter.Infrastructure;
+
+namespace RateLimiter.Implementations;
+
+public class RateLimiter : IRateLimiter
+{
+ private readonly ILogger _logger;
+ private readonly IRequestsRepository _requestsRepository;
+ private readonly IEnumerable _rules;
+
+ public RateLimiter(IEnumerable rules, IRequestsRepository requestsRepository, ILogger logger)
+ {
+ _rules = rules;
+ _requestsRepository = requestsRepository;
+ _logger = logger;
+ }
+
+ public void LimitRequestsForToken(string token)
+ {
+ var previousRequests = _requestsRepository.GetPreviousRequests(token);
+
+ try
+ {
+ foreach (var rule in _rules)
+ {
+ rule.Validate(token, previousRequests);
+ }
+ }
+ catch (RateLimitException ex)
+ {
+ _logger.LogInformation("Too many Attempts for Token: {Token}; Rule: {Rule}.", token, ex.RuleType);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Implementations/SpecificRateLimiterRule.cs b/RateLimiter/Implementations/SpecificRateLimiterRule.cs
new file mode 100644
index 00000000..0b6d74c7
--- /dev/null
+++ b/RateLimiter/Implementations/SpecificRateLimiterRule.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Collections.Generic;
+using RateLimiter.Abstractions;
+
+namespace RateLimiter.Implementations;
+
+public abstract class SpecificRateLimiterRule(Func? selector = null) : IRateLimiterRule
+{
+ private readonly Func _appliedRule = selector ?? (_ => true);
+
+ public void Validate(string token, IReadOnlyCollection previousRequests)
+ {
+ if (!_appliedRule(token))
+ {
+ return;
+ }
+
+ ValidateIfRequired(token, previousRequests);
+ }
+
+ protected abstract void ValidateIfRequired(string token, IReadOnlyCollection previousRequests);
+}
\ No newline at end of file
diff --git a/RateLimiter/Implementations/TimeWindowRateLimiterRule.cs b/RateLimiter/Implementations/TimeWindowRateLimiterRule.cs
new file mode 100644
index 00000000..7480ad27
--- /dev/null
+++ b/RateLimiter/Implementations/TimeWindowRateLimiterRule.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using RateLimiter.Exceptions;
+
+namespace RateLimiter.Implementations;
+
+public class TimeWindowRateLimiterRule(TimeSpan timeSpan, int maxRequests, Func? selector = null) : SpecificRateLimiterRule(selector)
+{
+ protected override void ValidateIfRequired(string token, IReadOnlyCollection previousRequests)
+ {
+ if (previousRequests.Count(x => DateTime.UtcNow - x <= timeSpan) >= maxRequests)
+ {
+ throw new RateLimitException("Expected less requests", nameof(TimeWindowRateLimiterRule));
+ }
+ }
+}
\ No newline at end of file
diff --git a/RateLimiter/Infrastructure/IRequestsRepository.cs b/RateLimiter/Infrastructure/IRequestsRepository.cs
new file mode 100644
index 00000000..2f7d61ac
--- /dev/null
+++ b/RateLimiter/Infrastructure/IRequestsRepository.cs
@@ -0,0 +1,10 @@
+using System;
+using System.Collections.Generic;
+
+namespace RateLimiter.Infrastructure;
+
+public interface IRequestsRepository
+{
+ public IReadOnlyCollection GetPreviousRequests(string token);
+ public void AddRequest(string token, DateTime date);
+}
\ No newline at end of file
diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj
index 19962f52..48268ae9 100644
--- a/RateLimiter/RateLimiter.csproj
+++ b/RateLimiter/RateLimiter.csproj
@@ -4,4 +4,7 @@
latest
enable
+
+
+
\ No newline at end of file