Skip to content

Commit

Permalink
Jun Park
Browse files Browse the repository at this point in the history
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
junsp committed Nov 11, 2024
1 parent d0a5741 commit 0b02586
Show file tree
Hide file tree
Showing 41 changed files with 956 additions and 7 deletions.
13 changes: 13 additions & 0 deletions .idea/.idea.RateLimiter/.idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .idea/.idea.RateLimiter/.idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/.idea.RateLimiter/.idea/indexLayout.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/.idea.RateLimiter/.idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 94 additions & 0 deletions RateLimiter.Tests/RateLimitRuleFactoryTest.cs
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]);
}
}
1 change: 1 addition & 0 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</ItemGroup>
<ItemGroup>
<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
93 changes: 87 additions & 6 deletions RateLimiter.Tests/RateLimiterTest.cs
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);
}
}
6 changes: 6 additions & 0 deletions RateLimiter.sln
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Services.Common", "Services.Common\Services.Common.csproj", "{C5A4B8D3-1BD2-4673-A77F-1A6ACB35826D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -26,6 +28,10 @@ Global
{C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.Build.0 = Release|Any CPU
{C5A4B8D3-1BD2-4673-A77F-1A6ACB35826D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5A4B8D3-1BD2-4673-A77F-1A6ACB35826D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5A4B8D3-1BD2-4673-A77F-1A6ACB35826D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5A4B8D3-1BD2-4673-A77F-1A6ACB35826D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
10 changes: 10 additions & 0 deletions RateLimiter.sln.DotSettings.user
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">&lt;AssemblyExplorer&gt;&#xD;
&lt;Assembly Path="C:\Users\junsp\.nuget\packages\microsoft.aspnetcore.app.ref\6.0.25\ref\net6.0\Microsoft.AspNetCore.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=68435dc8_002D3305_002D4cb1_002D8aea_002D93c672ba89f4/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="RateLimiterTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;TestAncestor&gt;&#xD;
&lt;TestId&gt;NUnit3x::C4F9249B-010E-46BE-94B8-DD20D82F1E60::net6.0::RateLimiter.Tests.RateLimiterTest&lt;/TestId&gt;&#xD;
&lt;TestId&gt;NUnit3x::C4F9249B-010E-46BE-94B8-DD20D82F1E60::net6.0::RateLimiter.Tests.RateLimitRuleFactoryTest&lt;/TestId&gt;&#xD;
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>
25 changes: 25 additions & 0 deletions RateLimiter/Controllers/PropertyController.cs
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);
}
}
25 changes: 25 additions & 0 deletions RateLimiter/Controllers/UserController.cs
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);
}
}
47 changes: 47 additions & 0 deletions RateLimiter/MiddleWares/RateLimitMiddleware.cs
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
}
}
Loading

0 comments on commit 0b02586

Please sign in to comment.