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