-
Notifications
You must be signed in to change notification settings - Fork 232
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This is for the take home assignment for staff engineer position in Crexi. I thought it would be nice to kinda show end to end work including example endpoints (controllers) as actual API resource examples. So some works were done to present Swagger as UI to play with. launchSettings.json is provided to run the solution, choose profile "RateLimiter" to run and play. Swagger UI will appear at: http://localhost:5000/swagger/index.html Since there were some mock data seeded as InMemoryDatabase injected in Program.cs to simulate data available, in the swagger Custom-Header below can be used to test: { "Id" : "296035ED-532E-46C9-8172-60806CC86B50", "Region": "US" } In actual production, this can be replaced to actual database or Redis, etc. There were Users data and Properties data resources to represent different API resources and mock data were seeded as InMemoryDatabase to play with. However all the interesting stuff is in the Services.Common project considering this work would be cross cutting concern, to be reused and exploited in any endpoints and API projects. So maybe if this is actually delivered in production, maybe provided as infrastructure layer work, either as common library (nuget pkg) or maybe gateway implmentation before actually reaching each endpoint. This work considers dynamic loading of configurations. It's done via json format, which in production could be retrieved in database or redis, etc. However for simplisity, there is 'rateLimitConfig.json' seeded in this project, which will represent per region, per resource configurations of rules. This configurations are loaded via IRuleConfigLoader with RuleCofigLoader, which is backgroundservice and currently set to refresh every 24 hours considering rule configurations wouldn't change frequently. How the rules results aggregated are determined by IRateLimiter, with current example of DynamicRateLimiter, which applies condition of all rules required should pass to let the request reach the API resource. IRateLimitFactory will instantantiate each required rule, which will contain cache getting updated per client states to calculate rule result for each client. Each rule implements IRateLimitRule to be compliant to aggregated abstracted IsRequestAllowed call. Middleware was used to check the rules before letting request reach each API endpoint resource.
- Loading branch information
Showing
41 changed files
with
956 additions
and
7 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using NUnit.Framework; | ||
using Services.Common.Configurations; | ||
using Services.Common.RateLimitRules; | ||
|
||
namespace RateLimiter.Tests; | ||
|
||
[TestFixture] | ||
public class RateLimitRuleFactoryTest | ||
{ | ||
private RateLimitRuleFactory _factory; | ||
|
||
[SetUp] | ||
public void SetUp() | ||
{ | ||
_factory = new RateLimitRuleFactory(); | ||
} | ||
|
||
[Test] | ||
public void CreateRules_ShouldReturnRequestPerTimespanRule_WhenRuleTypeIsRequestPerTimespan() | ||
{ | ||
// Arrange | ||
var config = new RuleConfig | ||
{ | ||
RuleTypes = new List<string> { "RequestPerTimespan" }, | ||
RequestLimit = 10, | ||
Timespan = TimeSpan.FromMinutes(1) | ||
}; | ||
|
||
// Act | ||
var rules = _factory.CreateRules(config).ToList(); | ||
|
||
// Assert | ||
Assert.AreEqual(1, rules.Count); | ||
Assert.IsInstanceOf<RequestPerTimespanRule>(rules.First()); | ||
var rule = (RequestPerTimespanRule)rules.First(); | ||
} | ||
|
||
[Test] | ||
public void CreateRules_ShouldReturnTimeSinceLastCallRule_WhenRuleTypeIsTimeSinceLastCall() | ||
{ | ||
// Arrange | ||
var config = new RuleConfig | ||
{ | ||
RuleTypes = new List<string> { "TimeSinceLastCall" }, | ||
MinIntervalBetweenRequests = TimeSpan.FromSeconds(5) | ||
}; | ||
|
||
// Act | ||
var rules = _factory.CreateRules(config).ToList(); | ||
|
||
// Assert | ||
Assert.AreEqual(1, rules.Count); | ||
Assert.IsInstanceOf<TimeSinceLastCallRule>(rules.First()); | ||
var rule = (TimeSinceLastCallRule)rules.First(); | ||
} | ||
|
||
[Test] | ||
public void CreateRules_ShouldThrowArgumentException_WhenRuleTypeIsUnknown() | ||
{ | ||
// Arrange | ||
var config = new RuleConfig | ||
{ | ||
RuleTypes = new List<string> { "UnknownRuleType" } | ||
}; | ||
|
||
// Act & Assert | ||
var exception = Assert.Throws<ArgumentException>(() => _factory.CreateRules(config).ToList()); | ||
Assert.AreEqual("Unknown rule type: UnknownRuleType", exception.Message); | ||
} | ||
|
||
[Test] | ||
public void CreateRules_ShouldReturnMultipleRules_WhenMultipleRuleTypesAreConfigured() | ||
{ | ||
// Arrange | ||
var config = new RuleConfig | ||
{ | ||
RuleTypes = new List<string> { "RequestPerTimespan", "TimeSinceLastCall" }, | ||
RequestLimit = 5, | ||
Timespan = TimeSpan.FromMinutes(1), | ||
MinIntervalBetweenRequests = TimeSpan.FromSeconds(10) | ||
}; | ||
|
||
// Act | ||
var rules = _factory.CreateRules(config).ToList(); | ||
|
||
// Assert | ||
Assert.AreEqual(2, rules.Count); | ||
Assert.IsInstanceOf<RequestPerTimespanRule>(rules[0]); | ||
Assert.IsInstanceOf<TimeSinceLastCallRule>(rules[1]); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,94 @@ | ||
using NUnit.Framework; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Moq; | ||
using NUnit.Framework; | ||
using Services.Common.Configurations; | ||
using Services.Common.Models; | ||
using Services.Common.RateLimiters; | ||
using Services.Common.RateLimitRules; | ||
using Services.Common.Repositories; | ||
|
||
namespace RateLimiter.Tests; | ||
|
||
[TestFixture] | ||
public class RateLimiterTest | ||
{ | ||
[Test] | ||
public void Example() | ||
{ | ||
Assert.That(true, Is.True); | ||
} | ||
private DynamicRateLimiter _rateLimiter; | ||
private Mock<IRuleRepository> _mockRuleRepository; | ||
private Mock<IRateLimitRuleFactory> _mockRuleFactory; | ||
private Mock<IRuleConfigLoader> _mockConfigLoader; | ||
private Mock<IRateLimitRule> _mockRule1; | ||
private Mock<IRateLimitRule> _mockRule2; | ||
|
||
[SetUp] | ||
public async Task SetUp() | ||
{ | ||
_mockRuleRepository = new Mock<IRuleRepository>(); | ||
_mockRuleFactory = new Mock<IRateLimitRuleFactory>(); | ||
_mockConfigLoader = new Mock<IRuleConfigLoader>(); | ||
_mockRule1 = new Mock<IRateLimitRule>(); | ||
_mockRule2 = new Mock<IRateLimitRule>(); | ||
_rateLimiter = new DynamicRateLimiter(_mockConfigLoader.Object); | ||
} | ||
|
||
[Test] | ||
public void IsRequestAllowed_ShouldReturnTrue_WhenAllRulesAllowRequest() | ||
{ | ||
// Arrange | ||
var token = new RateLimitToken { Id = Guid.NewGuid(), Resource = "api1", Region = "us-west" }; | ||
|
||
// Set up mocks to return true for all rules | ||
_mockRule1.Setup(r => r.IsRequestAllowed(token.Id)).Returns(true); | ||
_mockRule2.Setup(r => r.IsRequestAllowed(token.Id)).Returns(true); | ||
|
||
// Configure the mock config loader to return both rules | ||
_mockConfigLoader.Setup(c => c.GetRulesForResource(token.Resource, token.Region)) | ||
.Returns(new List<IRateLimitRule> { _mockRule1.Object, _mockRule2.Object }); | ||
|
||
// Act | ||
var result = _rateLimiter.IsRequestAllowed(token); | ||
|
||
// Assert | ||
Assert.IsTrue(result); | ||
} | ||
|
||
[Test] | ||
public void IsRequestAllowed_ShouldReturnFalse_WhenAnyRuleDeniesRequest() | ||
{ | ||
// Arrange | ||
var token = new RateLimitToken { Id = Guid.NewGuid(), Resource = "api2", Region = "us-east" }; | ||
|
||
// Set up mocks: one rule allows, one denies | ||
_mockRule1.Setup(r => r.IsRequestAllowed(token.Id)).Returns(true); | ||
_mockRule2.Setup(r => r.IsRequestAllowed(token.Id)).Returns(false); | ||
|
||
// Configure the mock config loader to return both rules | ||
_mockConfigLoader.Setup(c => c.GetRulesForResource(token.Resource, token.Region)) | ||
.Returns(new List<IRateLimitRule> { _mockRule1.Object, _mockRule2.Object }); | ||
|
||
// Act | ||
var result = _rateLimiter.IsRequestAllowed(token); | ||
|
||
// Assert | ||
Assert.IsFalse(result); | ||
} | ||
|
||
[Test] | ||
public void IsRequestAllowed_ShouldReturnTrue_WhenNoRulesExistForResource() | ||
{ | ||
// Arrange | ||
var token = new RateLimitToken { Id = Guid.NewGuid(), Resource = "api3", Region = "eu-central" }; | ||
|
||
// Configure the mock config loader to return an empty rule list | ||
_mockConfigLoader.Setup(c => c.GetRulesForResource(token.Resource, token.Region)) | ||
.Returns(new List<IRateLimitRule>()); | ||
|
||
// Act | ||
var result = _rateLimiter.IsRequestAllowed(token); | ||
|
||
// Assert | ||
Assert.IsTrue(result); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> | ||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
 | ||
<Assembly Path="C:\Users\junsp\.nuget\packages\microsoft.aspnetcore.app.ref\6.0.25\ref\net6.0\Microsoft.AspNetCore.dll" />
 | ||
</AssemblyExplorer></s:String> | ||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=68435dc8_002D3305_002D4cb1_002D8aea_002D93c672ba89f4/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="RateLimiterTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
 | ||
<TestAncestor>
 | ||
<TestId>NUnit3x::C4F9249B-010E-46BE-94B8-DD20D82F1E60::net6.0::RateLimiter.Tests.RateLimiterTest</TestId>
 | ||
<TestId>NUnit3x::C4F9249B-010E-46BE-94B8-DD20D82F1E60::net6.0::RateLimiter.Tests.RateLimitRuleFactoryTest</TestId>
 | ||
</TestAncestor>
 | ||
</SessionState></s:String></wpf:ResourceDictionary> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Threading.Tasks; | ||
using Microsoft.AspNetCore.Mvc; | ||
using RateLimiter.ViewModel; | ||
using Services.Common.Repositories; | ||
|
||
namespace RateLimiter.Controllers; | ||
|
||
[ApiController] | ||
[Route("api/[controller]")] | ||
public class PropertyController(IDataRepository<Property> repo) : Controller | ||
{ | ||
[HttpGet(Name = "GetProperties")] | ||
public async Task<IEnumerable<Property>> GetAllAsync() | ||
{ | ||
return await repo.GetAllAsync().ConfigureAwait(false); | ||
} | ||
|
||
[HttpGet("{id}", Name = "GetProperty")] | ||
public async Task<Property> GetByIdAsync(Guid id) | ||
{ | ||
return await repo.GetByIdAsync(id).ConfigureAwait(false); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Threading.Tasks; | ||
using Microsoft.AspNetCore.Mvc; | ||
using RateLimiter.ViewModel; | ||
using Services.Common.Repositories; | ||
|
||
namespace RateLimiter.Controllers; | ||
|
||
[ApiController] | ||
[Route("api/[controller]")] | ||
public class UserController(IDataRepository<User> repo) : Controller | ||
{ | ||
[HttpGet(Name = "GetUsers")] | ||
public async Task<IEnumerable<User>> GetAllAsync() | ||
{ | ||
return await repo.GetAllAsync().ConfigureAwait(false); | ||
} | ||
|
||
[HttpGet("{id}", Name = "GetUser")] | ||
public async Task<User> GetByIdAsync(Guid id) | ||
{ | ||
return await repo.GetByIdAsync(id).ConfigureAwait(false); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Text.Json; | ||
using System.Threading.Tasks; | ||
using Microsoft.AspNetCore.Http; | ||
using Services.Common.Configurations; | ||
using Services.Common.Models; | ||
using Services.Common.RateLimiters; | ||
|
||
namespace RateLimiter.MiddleWares; | ||
|
||
public class RateLimitMiddleware | ||
{ | ||
private readonly RequestDelegate _next; | ||
private readonly IRateLimiter _rateLimiter; | ||
|
||
public RateLimitMiddleware(RequestDelegate next, IRateLimiter rateLimiter) | ||
{ | ||
_next = next; | ||
_rateLimiter = rateLimiter; | ||
} | ||
|
||
public async Task InvokeAsync(HttpContext context) | ||
{ | ||
var jsonToken = context.Request.Headers["Custom-Header"].ToString(); // Assuming token in headers | ||
var token = JsonSerializer.Deserialize<RateLimitToken>(jsonToken); | ||
//var region = context.Request.Headers["Region"].ToString(); // Or determine region by token lookup | ||
|
||
if (token is null || string.IsNullOrEmpty(token?.Id.ToString())) | ||
{ | ||
context.Response.StatusCode = StatusCodes.Status401Unauthorized; | ||
await context.Response.WriteAsync("Authorization token is missing."); | ||
return; | ||
} | ||
|
||
token.Resource = context.Request.Path.ToString(); // Use the endpoint path as resource identifier | ||
|
||
if (!_rateLimiter.IsRequestAllowed(token)) | ||
{ | ||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests; | ||
await context.Response.WriteAsync("Rate limit exceeded. Please try again later."); | ||
return; | ||
} | ||
|
||
await _next(context); // Continue to the next middleware or endpoint | ||
} | ||
} |
Oops, something went wrong.