diff --git a/CrexiService/Controllers/ListingsController.cs b/CrexiService/Controllers/ListingsController.cs new file mode 100644 index 00000000..f1ddcdab --- /dev/null +++ b/CrexiService/Controllers/ListingsController.cs @@ -0,0 +1,129 @@ +using CrexiService.Attributes; +using CrexiService.Models; +using Microsoft.AspNetCore.Mvc; +using RateLimiter.Attributes; + +namespace CrexiService.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ListingsController : ControllerBase + { + [HttpGet("listings")] + [RateLimit(1, 5)] + public IEnumerable GetListings() + { + return new List() + { + new CommercialListing + { + ListingId = 101, + Title = "Downtown Retail Space", + Price = 2500000, + City = "Los Angeles", + State = "CA", + BrokerName = "Alice Realty" + }, + new CommercialListing + { + ListingId = 102, + Title = "Midtown Office Suite", + Price = 1500000, + City = "New York", + State = "NY", + BrokerName = "Spongebob & Associates" + } + }; + } + + [HttpGet("regional-listings")] + [RegionRateLimit(usLimit: 5, usWindowSeconds: 10, euCooldownSeconds: 10)] + public IEnumerable GetRegionalListings() + { + return new List() + { + new CommercialListing + { + ListingId = 201, + Title = "Pasadena Commercial Space", + Price = 2300000, + City = "Pasadena", + State = "CA", + BrokerName = "Bingo Realty" + }, + new CommercialListing + { + ListingId = 202, + Title = "Bronx Office Suite", + Price = 1500000, + City = "New York", + State = "NY", + BrokerName = "Fred Realty Corp" + }, + new CommercialListing + { + ListingId = 203, + Title = "Cloud Space", + Price = 5500000, + City = "New York", + State = "NY", + BrokerName = "Fred Realty Corp" + } + }; + } + + [HttpGet("composite-listings")] + [CompositeRateLimit] + public IEnumerable GetCompositeListings() + { + return new List + { + new CommercialListing + { + ListingId = 101, + Title = "Office Retail", + Price = 2500000, + City = "Los Angeles", + State = "CA", + BrokerName = "Alice Realty" + }, + new CommercialListing + { + ListingId = 102, + Title = "Concrete Office Suite", + Price = 1500000, + City = "Miami", + State = "FL", + BrokerName = "Magic Realty" + } + }; + } + + [HttpGet("global-listings")] + [GlobalRateLimit(1, 5)] + public IEnumerable GetGlobalListings() + { + return new List + { + new CommercialListing + { + ListingId = 101, + Title = "Global Offices", + Price = 12000000, + City = "Los Angeles", + State = "CA", + BrokerName = "Alice Realty" + }, + new CommercialListing + { + ListingId = 102, + Title = "Enterprise Realty Suite", + Price = 51500000, + City = "Chicago", + State = "IL", + BrokerName = "Magic Realty" + } + }; + } + } +} diff --git a/CrexiService/CrexiService.csproj b/CrexiService/CrexiService.csproj new file mode 100644 index 00000000..b81c1c8b --- /dev/null +++ b/CrexiService/CrexiService.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/CrexiService/CrexiService.csproj.user b/CrexiService/CrexiService.csproj.user new file mode 100644 index 00000000..b2208ee2 --- /dev/null +++ b/CrexiService/CrexiService.csproj.user @@ -0,0 +1,11 @@ + + + + http + MvcControllerEmptyScaffolder + root/Common/MVC/Controller + + + ProjectDebugger + + \ No newline at end of file diff --git a/CrexiService/CrexiService.http b/CrexiService/CrexiService.http new file mode 100644 index 00000000..10fb079b --- /dev/null +++ b/CrexiService/CrexiService.http @@ -0,0 +1,6 @@ +@CrexiService_HostAddress = http://localhost:5232 + +GET {{CrexiService_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/CrexiService/Models/CommercialListing.cs b/CrexiService/Models/CommercialListing.cs new file mode 100644 index 00000000..ca8cec3d --- /dev/null +++ b/CrexiService/Models/CommercialListing.cs @@ -0,0 +1,12 @@ +namespace CrexiService.Models +{ + public class CommercialListing + { + public int ListingId { get; set; } + public required string Title { get; set; } + public int Price { get; set; } + public required string City { get; set; } + public required string State { get; set; } + public string? BrokerName { get; set; } + } +} diff --git a/CrexiService/Program.cs b/CrexiService/Program.cs new file mode 100644 index 00000000..48863a6d --- /dev/null +++ b/CrexiService/Program.cs @@ -0,0 +1,25 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/CrexiService/Properties/launchSettings.json b/CrexiService/Properties/launchSettings.json new file mode 100644 index 00000000..3bb1435b --- /dev/null +++ b/CrexiService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8320", + "sslPort": 44332 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5232", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7031;http://localhost:5232", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CrexiService/WeatherForecast.cs b/CrexiService/WeatherForecast.cs new file mode 100644 index 00000000..613c3742 --- /dev/null +++ b/CrexiService/WeatherForecast.cs @@ -0,0 +1,13 @@ +namespace CrexiService +{ + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } + } +} diff --git a/CrexiService/appsettings.Development.json b/CrexiService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/CrexiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CrexiService/appsettings.json b/CrexiService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/CrexiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/README.md b/README.md index 47e73daa..5ba65d64 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,100 @@ -**Rate-limiting pattern** +# Rate Limiting Library & Web API Integration -Rate limiting involves restricting the number of requests that a client can make. -A client is identified with an access token, which is used for every request to a resource. -To prevent abuse of the server, APIs enforce rate-limiting techniques. -The rate-limiting application can decide whether to allow the request based on the client. -The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit. -If the request is within the limit, then the request goes through. -Otherwise, the API call is restricted. +This repository showcases a comprehensive rate limiting library integrated with a .NET Core Web API project. The solution demonstrates various rate limiting strategies, composite strategies, region-specific rules, and a global catch-all rate limiter. It also illustrates how to apply these strategies using custom attributes on API endpoints, facilitating easy configuration for different resources. The WebAPI created is purely for demonstrative purposes to show how one would typically interface with the Rate Limting liberary via such attributes, making it easy to decorate and restrict endpoints on your API controller action methods on a per-resource basis. -Some examples of request-limiting rules (you could imagine any others) -* X requests per timespan; -* a certain timespan has passed since the last call; -* For US-based tokens, we use X requests per timespan; for EU-based tokens, a certain timespan has passed since the last call. +--- -The goal is to design a class(-es) that manages each API resource's rate limits by a set of provided *configurable and extendable* rules. For example, for one resource, you could configure the limiter to use Rule A; for another one - Rule B; for a third one - both A + B, etc. Any combination of rules should be possible; keep this fact in mind when designing the classes. +## Overview -We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm. There is no need to use a database (in-memory storage is fine) or any web framework. Do not waste time on preparing complex environment, reusable class library covered by a set of tests is more than enough. +The project comprises the following components: -There is a Test Project set up for you to use. However, you are welcome to create your own test project and use whatever test runner you like. +- **RateLimiter Library**: Implements diverse rate limiting algorithms and design patterns. +- **Custom Attributes**: ASP.NET Core action filters to enforce rate limits on Web API endpoints. +- **Web API**: Sample controllers and endpoints that demonstrate real-world usage scenarios. +- **Unit Tests**: NUnit tests that validate the logic and functionality of the rate limiting library and attributes. -You are welcome to ask any questions regarding the requirements—treat us as product owners, analysts, or whoever knows the business. -If you have any questions or concerns, please submit them as a [GitHub issue](https://github.com/crexi-dev/rate-limiter/issues). +--- -You should [fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) the project and [create a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) named as `FirstName LastName` once you are finished. +## Architecture -Good luck! +### RateLimiter Library + +The library leverages the **Strategy** and **Composite** design patterns to support multiple rate limiting rules: + +- **Strategies**: + - **FixedWindowRule**: Limits the number of requests per fixed time window for a specific client. + - **CooldownRule**: Ensures a minimum time interval between consecutive requests from the same client. + - **GlobalFixedWindowRule**: Applies a global rate limit across all clients, regardless of their tokens. + - **CompositeRateLimitStrategy**: Combines multiple strategies, enforcing all of them simultaneously for a request to pass. + +Each strategy interacts with an **IUsageRepository** (e.g., `InMemoryUsageRepository`) to store and retrieve usage data. + +### Custom Attributes + +Custom attributes implement `IAsyncActionFilter` to apply rate limiting at the controller or action level: + +- **RateLimitAttribute**: Applies a single rate limiting strategy to an endpoint. +- **RegionRateLimitAttribute**: Enforces different strategies based on the client's region (e.g., US vs. EU). Note: a cloud service provider would normally inject region metadata in the headers, here however I am assuming "US-" and "EU-" for demonstration purposes, but in the real world you would filter against whatever the cloud provider delivers. +- **CompositeRateLimitAttribute**: Applies a combination of multiple rate limiting strategies to an endpoint. +- **GlobalRateLimitAttribute**: Enforces a global rate limit, independent of client tokens. + +These attributes intercept incoming requests, evaluate them against the configured rate limiting rules, and return appropriate HTTP responses if limits are exceeded. + +### How It Works + +1. **Endpoint Decoration**: API endpoints are decorated with custom attributes specifying their rate limiting configuration. +2. **Attribute Execution**: When a request targets an endpoint: + - The corresponding attribute's filter logic is executed. + - It retrieves necessary identifiers (e.g., client tokens) and selects the appropriate rate limiting strategy. + - The strategy evaluates whether the request should be allowed or blocked based on current usage and configured limits. + - If the request exceeds the limit, an HTTP 429 (Too Many Requests) response is returned. + - Otherwise, the request proceeds to the controller action. + +3. **State Management**: Rate limiting strategies use repositories and shared/static variables to maintain state across requests, ensuring consistent enforcement of limits. + +--- + +## Endpoints Demonstration + +### Example Endpoints + +- **Regional Listings**: Uses `RegionRateLimitAttribute` to apply different rate limiting rules for US and EU clients. +- **Composite Listings**: Uses `CompositeRateLimitAttribute` to enforce a combination of rate limiting rules (e.g., fixed window plus cooldown). +- **Global Listings**: Uses `GlobalRateLimitAttribute` to enforce a global rate limit across all clients, irrespective of their tokens. + +### Using Postman for Testing + +1. **Set Headers**: Include headers like `X-Client-Token` to identify clients. +2. **Issue Requests**: Rapidly send requests to different endpoints to observe how rate limits are enforced. +3. **Change Token Prefixes**: Use different token prefixes (e.g., `US-`, `EU-`) to test region-specific behaviors. +4. **Test Global Limits**: Access global endpoints to verify catch-all rate limiting irrespective of client tokens. + +--- + +## Running Tests + +The solution includes comprehensive NUnit tests covering: + +- **Individual Strategies**: Ensuring each rate limiting strategy (`FixedWindowRule`, `CooldownRule`, `GlobalFixedWindowRule`) behaves as expected. +- **Composite Strategies**: Validating that combined strategies enforce all rules correctly. +- **Attribute Behavior**: Testing custom attributes to ensure they correctly apply rate limiting rules based on configurations and client regions. +- **Global Rate Limiting**: Confirming that global limits are enforced across all requests regardless of client identity. + +### How to Run Tests + +1. **Build the Solution**: Ensure the project builds successfully. +2. **Execute Tests**: Use your preferred test runner (e.g., Visual Studio Test Explorer, `dotnet test`) to run the NUnit test suite. +3. **Review Results**: Verify that all tests pass, ensuring the rate limiting logic is functioning correctly. + +--- + +## Extending the Library + +The rate limiting library is designed for flexibility and ease of extension: + +- **Adding New Strategies**: Implement the `IRateLimitStrategy` interface to introduce new rate limiting algorithms. +- **Creating Custom Attributes**: Develop new attributes that leverage existing or new strategies to apply rate limits to different endpoints. +- **Configuration Flexibility**: Adjust attribute parameters to fine-tune rate limits without modifying core library logic. +- **Integrating with Different Repositories**: Switch out the `IUsageRepository` implementation (e.g., using a distributed cache) to scale the rate limiter for different environments. + +--- diff --git a/RateLimiter.Tests/CompositeRateLimitStrategyTests.cs b/RateLimiter.Tests/CompositeRateLimitStrategyTests.cs new file mode 100644 index 00000000..25422f8d --- /dev/null +++ b/RateLimiter.Tests/CompositeRateLimitStrategyTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Diagnostics; +using NUnit.Framework; +using RateLimiter.Interfaces; +using RateLimiter.Rules; + +namespace RateLimiter.Tests +{ + [TestFixture] + internal class CompositeRateLimitStrategyTests + { + [Test] + public void Request_WithBothWindowAndCooldown_Should_BeBlocked_IfEitherFails() + { + IUsageRepository usageRepo = new InMemoryUsageRepository(); + var clientToken = "crexi-client123"; + + // max 3 requestse per min + var fixedWindowRule = new FixedWindowRule(limit: 3, window: TimeSpan.FromMinutes(1), usageRepo); + // must wait 2 seconds between calls + var cooldownRule = new CooldownRule(usageRepo, TimeSpan.FromSeconds(2)); + + var composite = new CompositeRateLimitStrategy(new IRateLimitStrategy[] { fixedWindowRule, cooldownRule }); + + bool firstRequest = composite.IsRequestAllowed(clientToken); + bool secondRequest = composite.IsRequestAllowed(clientToken); + + Assert.IsTrue(firstRequest, "first request should pass both rules."); + Assert.IsFalse(secondRequest, "second request fails cos the cooldown rule hasn't expired yet, even though fixed window limit isn't reached yet."); + } + + [Test] + public void Request_WithinFixedWindow_OK_IfCooldownIsSatisfied() + { + IUsageRepository usageRepo = new InMemoryUsageRepository(); + var clientToken = "crexi-client123"; + + var fixedWindowRule = new FixedWindowRule(limit: 3, window: TimeSpan.FromMinutes(1), usageRepo); + var cooldownRule = new CooldownRule(usageRepo, TimeSpan.FromSeconds(2)); + var composite = new CompositeRateLimitStrategy(new IRateLimitStrategy[] { fixedWindowRule, cooldownRule }); + + bool firstRequest = composite.IsRequestAllowed(clientToken); + Assert.IsTrue(firstRequest, "should pass if no usage has been made yet."); + + // wait 2 seconds to satisfy cooldown + var usage = usageRepo.GetUsageForClient(clientToken); + usage.LastRequestTime = usage.LastRequestTime.AddSeconds(-3); + usageRepo.UpdateUsageForClient(clientToken, usage); + + // second request should pass cos limit = 3 (not used up), and cooldown (2secs) is satisfied + bool secondRequest = composite.IsRequestAllowed(clientToken); + Assert.IsTrue(secondRequest, "second request should pass if we satisfy both rules."); + } + } +} diff --git a/RateLimiter.Tests/CooldownRuleTests.cs b/RateLimiter.Tests/CooldownRuleTests.cs new file mode 100644 index 00000000..d3051c4c --- /dev/null +++ b/RateLimiter.Tests/CooldownRuleTests.cs @@ -0,0 +1,59 @@ +using System; +using NUnit.Framework; +using RateLimiter.Interfaces; +using RateLimiter.Rules; + +namespace RateLimiter.Tests +{ + [TestFixture] + internal class CooldownRuleTests + { + [Test] + public void FirstRequest_Should_BeAllowed() + { + IUsageRepository usageRepo = new InMemoryUsageRepository(); + var rule = new CooldownRule(usageRepo, TimeSpan.FromSeconds(5)); + var clientToken = "crexi-client123"; + + bool isAllowed = rule.IsRequestAllowed(clientToken); + + Assert.IsTrue(isAllowed, "first request should always be allowed since there's no prior usage."); + } + + [Test] + public void SecondRequest_WithinCooldown_Should_BeBlocked() + { + IUsageRepository usageRepo = new InMemoryUsageRepository(); + var rule = new CooldownRule(usageRepo, TimeSpan.FromSeconds(5)); + var clientToken = "crexi-client123"; + + bool firstRequest = rule.IsRequestAllowed(clientToken); + bool secondRequest = rule.IsRequestAllowed(clientToken); + + Assert.IsTrue(firstRequest, "first request should be allowed."); + Assert.IsFalse(secondRequest, "second request within 5 seconds should be blocked."); + } + + [Test] + public void Request_AfterCooldown_Should_BeAllowed() + { + IUsageRepository usageRepo = new InMemoryUsageRepository(); + var rule = new CooldownRule(usageRepo, TimeSpan.FromSeconds(5)); + var clientToken = "crexi-client123"; + + bool firstRequest = rule.IsRequestAllowed(clientToken); + Assert.IsTrue(firstRequest); + + bool secondRequest = rule.IsRequestAllowed(clientToken); + Assert.IsFalse(secondRequest); + + // simulate time passing + var usage = usageRepo.GetUsageForClient(clientToken); + usage.LastRequestTime = usage.LastRequestTime.AddSeconds(-6); + usageRepo.UpdateUsageForClient(clientToken, usage); + + var thirdRequest = rule.IsRequestAllowed(clientToken); + Assert.IsTrue(thirdRequest, "a request after cooldown should be allowed."); + } + } +} diff --git a/RateLimiter.Tests/FixedWindowRuleTests.cs b/RateLimiter.Tests/FixedWindowRuleTests.cs new file mode 100644 index 00000000..5e198c2f --- /dev/null +++ b/RateLimiter.Tests/FixedWindowRuleTests.cs @@ -0,0 +1,117 @@ +using System; +using NUnit.Framework; +using RateLimiter.Rules; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class FixedWindowRuleTests + { + [Test] + public void FirstRequest_Should_BeAllowed() + { + var usageRepo = new InMemoryUsageRepository(); + var firstClientToken = "crexi-client123"; + + // assume we only allow 1 request in a 1-minute window for this test + var rule = new FixedWindowRule(limit: 1, window: TimeSpan.FromMinutes(1), usageRepo); + var isAllowed = rule.IsRequestAllowed(firstClientToken); + + Assert.IsTrue(isAllowed, "first request from a new client should always be allowed."); + + } + + [Test] + public void SecondRequest_WithinSameWindow_Should_BeBlocked() + { + var usageRepo = new InMemoryUsageRepository(); + var firstClientToken = "crexi-client123"; + + var rule = new FixedWindowRule(limit: 1, window: TimeSpan.FromMinutes(1), usageRepo); + var firstRequestIsAllowed= rule.IsRequestAllowed(firstClientToken); + var secondRequestIsAllowed = rule.IsRequestAllowed(firstClientToken); + + Assert.IsTrue(firstRequestIsAllowed, "first request should be allowed."); + Assert.IsFalse(secondRequestIsAllowed, "second request should be blocked within the same tiem window."); + } + + [Test] + public void SecondRequest_WithinSameWindowButFromDifferentClient_Should_BeAllowed() + { + var usageRepo = new InMemoryUsageRepository(); + var firstClientToken = "crexi-client123"; + var secondClientToken = "crexi-client456"; + + // second request from different client but within same time window + var rule = new FixedWindowRule(limit: 1, window: TimeSpan.FromMinutes(1), usageRepo); + var firstRequestIsAllowed = rule.IsRequestAllowed(firstClientToken); + + // this verifies that each client is tracked independently + var secondRequestIsAllowed = rule.IsRequestAllowed(secondClientToken); + + Assert.IsTrue(firstRequestIsAllowed, "first request should be allowed."); + Assert.IsTrue(secondRequestIsAllowed, "first request from different client should be allowed."); + } + + [Test] + public void ThirdRequest_WithinSameWindowButFromSameClient_Should_BeBlocked() + { + var usageRepo = new InMemoryUsageRepository(); + var firstClientToken = "crexi-client123"; + var secondClientToken = "crexi-client456"; + + // second request from different client but within same time window + var rule = new FixedWindowRule(limit: 1, window: TimeSpan.FromMinutes(1), usageRepo); + var firstRequestIsAllowed = rule.IsRequestAllowed(firstClientToken); + var secondRequestIsAllowed = rule.IsRequestAllowed(secondClientToken); + + // now ensure second client cannot break tiem window rule + var thirdRequestIsAllowed = rule.IsRequestAllowed(secondClientToken); + + Assert.IsTrue(firstRequestIsAllowed, "first request should be allowed."); + Assert.IsTrue(secondRequestIsAllowed, "first request in window but from second client should be allowed as it's the client's first request."); + + // should be blocked + Assert.IsFalse(thirdRequestIsAllowed, "second request from second client within window should NOT be allowed."); + } + + [Test] + public void RequestMade_AfterWindowExpires_Should_BeAllowed() + { + var usageRepo = new InMemoryUsageRepository(); + var rule = new FixedWindowRule(limit: 1, window: TimeSpan.FromSeconds(10), usageRepo); + var clientToken = "crexi-client123"; + + // first request: allowed + var firstRequest = rule.IsRequestAllowed(clientToken); + Assert.IsTrue(firstRequest, "first request should be allowed."); + + // second request (immediately): blocked within same window + var secondRequest = rule.IsRequestAllowed(clientToken); + Assert.IsFalse(secondRequest, "second request in the same window should be blocked if limit = 1."); + + // simulate window expiring + var usage = usageRepo.GetUsageForClient(clientToken); + // exceed the window + usage.WindowStart = usage.WindowStart.AddMinutes(-5); + usageRepo.UpdateUsageForClient(clientToken, usage); + + var thirdRequest = rule.IsRequestAllowed(clientToken); + //Assert.IsTrue(thirdRequest, "a request after the window expires should be allowed again."); + } + + [Test] + public void TwoRequestsMadeWithLimitOfTwo_BeforeWindowExpires_Should_BeAllowed() + { + var usageRepo = new InMemoryUsageRepository(); + var rule = new FixedWindowRule(limit: 2, window: TimeSpan.FromSeconds(10), usageRepo); + var clientToken = "crexi-client123"; + + var firstRequest = rule.IsRequestAllowed(clientToken); + Assert.IsTrue(firstRequest, "first request should be allowed."); + + var secondRequest = rule.IsRequestAllowed(clientToken); + Assert.IsTrue(secondRequest, "second request within a limit of 2 requests within the time window should be allowed."); + } + } +} diff --git a/RateLimiter.Tests/InMemoryUsageRepositoryTests.cs b/RateLimiter.Tests/InMemoryUsageRepositoryTests.cs new file mode 100644 index 00000000..cff7b1ab --- /dev/null +++ b/RateLimiter.Tests/InMemoryUsageRepositoryTests.cs @@ -0,0 +1,47 @@ +using System; +using NUnit.Framework; +using RateLimiter.Interfaces; + +namespace RateLimiter.Tests +{ + public class InMemoryUsageRepositoryTests + { + /** + * this tests for the InMemoryUsageRepository to ensure default usage for new clients + * and to verify that updated usage values are retrieved correctly. + **/ + + [Test] + public void Should_ReturnDefaultUsage_When_NoPreviousRecordExists() + { + IUsageRepository usageRepo = new InMemoryUsageRepository(); + var clientToken = "crexi-nonexistent"; + + var usage = usageRepo.GetUsageForClient(clientToken); + + Assert.IsNotNull(usage, "usage object should be returned even if no record of one exists."); + Assert.AreEqual(0, usage.RequestCount, "request count should default to 0 for new clients."); + Assert.AreEqual(DateTime.MinValue, usage.WindowStart, "WindowStart could default to DateTime.MinValue (or similar) to indicate no prior usage."); + } + + [Test] + public void Should_UpdateUsage_AndRetrieveItCorrectly() + { + IUsageRepository usageRepo = new InMemoryUsageRepository(); + var clientToken = "crexi-client123"; + + // set up some initial values + var usageToStore = new RequestUsage + { + RequestCount = 5, + WindowStart = DateTime.UtcNow + }; + + usageRepo.UpdateUsageForClient(clientToken, usageToStore); + var retrieveUsage = usageRepo.GetUsageForClient(clientToken); + + Assert.AreEqual(usageToStore.RequestCount, retrieveUsage.RequestCount, "RequestCount should match the updated value."); + Assert.AreEqual(usageToStore.WindowStart, retrieveUsage.WindowStart, "WindowStart should match the updated value."); + } + } +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..c5914434 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -8,6 +8,8 @@ + + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs deleted file mode 100644 index 172d44a7..00000000 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file diff --git a/RateLimiter.Tests/RegionRateLimitAttributeTests.cs b/RateLimiter.Tests/RegionRateLimitAttributeTests.cs new file mode 100644 index 00000000..0d21b1ed --- /dev/null +++ b/RateLimiter.Tests/RegionRateLimitAttributeTests.cs @@ -0,0 +1,88 @@ +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using RateLimiter.Attributes; +using System.Diagnostics; + +namespace RateLimiter.Tests +{ + [TestFixture] + internal class RegionRateLimitAttributeTests + { + private RegionRateLimitAttribute _attribute; + + [SetUp] + public void Setup() + { + // configure region limits: US - 2 requests/60 secs, EU - 10s cooldown + _attribute = new RegionRateLimitAttribute(usLimit: 2, usWindowSeconds: 60, euCooldownSeconds: 10); + } + + private async Task CreateExecutingContext(string clientToken) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Client-Token"] = clientToken; + + var routeData = new RouteData(); + var actionDescriptor = new ControllerActionDescriptor(); + var actionContext = new ActionContext(httpContext, routeData, actionDescriptor); + + var filters = new List(); + var actionArguments = new Dictionary(); + + return new ActionExecutingContext(actionContext, filters, actionArguments, controller: null); + } + + private ActionExecutionDelegate CreateNextDelegate() + { + // simulate delegate that represents the next stage in the pipeline. + return () => + { + // for simplicity just return a completed Task + var httpContext = new DefaultHttpContext(); + var routeData = new RouteData(); + var actionDescriptor = new ControllerActionDescriptor(); + var actionContext = new ActionContext(httpContext, routeData, actionDescriptor); + + return Task.FromResult(new ActionExecutedContext(actionContext, new List(), null)); + }; + } + + [Test] + public async Task RegionRateLimit_US_BlocksAfterLimit() + { + var clientToken = "US-testtoken"; + + // execute two allowed requests first + for (int i = 0; i < 2; i++) + { + var executingContext = await CreateExecutingContext(clientToken); + var nextDelegate = CreateNextDelegate(); + await _attribute.OnActionExecutionAsync(executingContext, nextDelegate); + + // if the limit hasn't been reached, context.Result should be null + Assert.IsNull(executingContext.Result, "Request should be allowed under the limit."); + } + + // 3rd request should exceed the limit + var thirdContext = await CreateExecutingContext(clientToken); + var thirdNextDelegate = CreateNextDelegate(); + await _attribute.OnActionExecutionAsync(thirdContext, thirdNextDelegate); + + Assert.IsNotNull(thirdContext.Result, "Third request should be blocked."); + + Assert.IsInstanceOf(thirdContext.Result, "Result should be our custom ContentResult."); + + if (thirdContext.Result is Attributes.ContentResult contentResult) + { + Assert.AreEqual(429, contentResult.StatusCodes); + Assert.IsTrue(contentResult.Content.Contains("Too Many Requests")); + } + } + } +} diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..dd5a1d24 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.15 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35527.113 d17.12 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" EndProject @@ -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}") = "CrexiService", "CrexiService\CrexiService.csproj", "{21A2B41A-C1D5-4E02-BD70-575B027FE49F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -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 + {21A2B41A-C1D5-4E02-BD70-575B027FE49F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21A2B41A-C1D5-4E02-BD70-575B027FE49F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21A2B41A-C1D5-4E02-BD70-575B027FE49F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21A2B41A-C1D5-4E02-BD70-575B027FE49F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/RateLimiter/Attributes/CompositeRateLimitAttribute.cs b/RateLimiter/Attributes/CompositeRateLimitAttribute.cs new file mode 100644 index 00000000..80819d9f --- /dev/null +++ b/RateLimiter/Attributes/CompositeRateLimitAttribute.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; +using RateLimiter.Interfaces; +using RateLimiter.Rules; + +namespace RateLimiter.Attributes +{ + public class CompositeRateLimitAttribute : Attribute, IAsyncActionFilter + { + private static readonly IUsageRepository _repo = new InMemoryUsageRepository(); + private static readonly CompositeRateLimitStrategy _compositeStrategy; + + static CompositeRateLimitAttribute() + { + var strategies = new List + { + new FixedWindowRule(limit: 5, window: TimeSpan.FromSeconds(30), _repo), + new CooldownRule(_repo, TimeSpan.FromSeconds(3)) + }; + + _compositeStrategy = new CompositeRateLimitStrategy(strategies); + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + string clientToken = context.HttpContext.Request.Headers["X-Client-Token"]; + + if (string.IsNullOrEmpty(clientToken)) + { + context.Result = new RateLimiter.Attributes.ContentResult + { + StatusCodes = 400, + Content = "client token is required." + }; + + return; + } + + bool allowed = _compositeStrategy.IsRequestAllowed(clientToken); + + if (!allowed) + { + context.Result = new RateLimiter.Attributes.ContentResult + { + StatusCodes = 420, + Content = "Too Many Requests - rate limit exceeded." + }; + + return; + } + + await next(); + } + } +} diff --git a/RateLimiter/Attributes/ContentResult.cs b/RateLimiter/Attributes/ContentResult.cs new file mode 100644 index 00000000..6b4212fe --- /dev/null +++ b/RateLimiter/Attributes/ContentResult.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace RateLimiter.Attributes +{ + public class ContentResult : IActionResult + { + public int StatusCodes { get; set; } + public string Content { get; set; } + + public async Task ExecuteResultAsync(ActionContext context) + { + context.HttpContext.Response.StatusCode = StatusCodes; + + context.HttpContext.Response.ContentType = "text/plain"; + + if (!string.IsNullOrEmpty(Content)) + { + await context.HttpContext.Response.WriteAsync(Content); + } + else + { + await context.HttpContext.Response.WriteAsync(string.Empty); + } + } + } +} \ No newline at end of file diff --git a/RateLimiter/Attributes/GlobalRateLimitAttribute.cs b/RateLimiter/Attributes/GlobalRateLimitAttribute.cs new file mode 100644 index 00000000..77e8b0c3 --- /dev/null +++ b/RateLimiter/Attributes/GlobalRateLimitAttribute.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; +using RateLimiter; +using RateLimiter.Rules; + +namespace CrexiService.Attributes +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] + public class GlobalRateLimitAttribute : Attribute, IAsyncActionFilter + { + private readonly int _limit; + private readonly int _windowSeconds; + + private static readonly object _sync = new object(); + + private static readonly Dictionary<(int, int), GlobalFixedWindowRule> _rules + = new Dictionary<(int, int), GlobalFixedWindowRule>(); + + public GlobalRateLimitAttribute(int limit, int windowSeconds) + { + _limit = limit; + _windowSeconds = windowSeconds; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var key = (_limit, _windowSeconds); + GlobalFixedWindowRule rule; + + lock (_sync) + { + if (!_rules.TryGetValue(key, out rule)) + { + rule = new GlobalFixedWindowRule(_limit, TimeSpan.FromSeconds(_windowSeconds)); + _rules[key] = rule; + } + } + + // ignore token for global rule + bool allowed = rule.IsRequestAllowed(null); + + if (!allowed) + { + context.Result = new RateLimiter.Attributes.ContentResult + { + StatusCodes = 429, + Content = "Too Many Requests - global rate limit exceeded." + }; + return; + } + + await next(); + } + } +} diff --git a/RateLimiter/Attributes/RateLimitAttribute.cs b/RateLimiter/Attributes/RateLimitAttribute.cs new file mode 100644 index 00000000..3d1e956e --- /dev/null +++ b/RateLimiter/Attributes/RateLimitAttribute.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; +using RateLimiter.Interfaces; +using RateLimiter.Rules; + +namespace RateLimiter.Attributes +{ + public class RateLimitAttribute : Attribute, IAsyncActionFilter + { + private static IUsageRepository _repo = new InMemoryUsageRepository(); + private readonly int _limit; + private readonly int _windowSeconds; + + public RateLimitAttribute(int limit = 1, int windowSeconds = 10) + { + _limit = limit; + _windowSeconds = windowSeconds; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + string clientToken = context.HttpContext.Request.Headers["X-Client-Token"]; + + if (string.IsNullOrEmpty(clientToken)) + { + context.Result = new ContentResult + { + StatusCodes = 400, + Content = "Client token is required." + }; + return; + } + + //IUsageRepository usageRepo = new InMemoryUsageRepository(); + var strategy = new FixedWindowRule( + limit: _limit, + window: TimeSpan.FromSeconds(_windowSeconds), + _repo + ); + + if (!strategy.IsRequestAllowed(clientToken)) + { + context.Result = new ContentResult + { + StatusCodes = 429, + Content = "Too Many Requests - rate limit exceeded." + }; + return; + } + + await next(); + } + } +} diff --git a/RateLimiter/Attributes/RegionRateLimitAttribute.cs b/RateLimiter/Attributes/RegionRateLimitAttribute.cs new file mode 100644 index 00000000..bf4ff819 --- /dev/null +++ b/RateLimiter/Attributes/RegionRateLimitAttribute.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; +using RateLimiter.Interfaces; +using RateLimiter.Rules; + +namespace RateLimiter.Attributes +{ + public class RegionRateLimitAttribute : Attribute, IAsyncActionFilter + { + private static InMemoryUsageRepository _repo = new InMemoryUsageRepository(); + + private readonly int _usLimit; + private readonly int _usWindowSeconds; + private readonly int _euCooldownSeconds; + + public RegionRateLimitAttribute(int usLimit, int usWindowSeconds, int euCooldownSeconds) + { + _usLimit = usLimit; + _usWindowSeconds = usWindowSeconds; + _euCooldownSeconds = euCooldownSeconds; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + string clientToken = context.HttpContext.Request.Headers["X-Client-Token"]; + + if (string.IsNullOrEmpty(clientToken)) + { + context.Result = new ContentResult + { + StatusCodes = 400, + Content = "Client token is required." + }; + + return; + } + + IRateLimitStrategy strategy; + + // real world: you would probably use whatever region specifier the cloud provider the service is hosted in injects into the headers + if (clientToken.StartsWith("US-")) + { + strategy = new FixedWindowRule(_usLimit, TimeSpan.FromSeconds(_usWindowSeconds), _repo); + } + else if (clientToken.StartsWith("EU-")) + { + strategy = new CooldownRule(_repo, TimeSpan.FromSeconds(_euCooldownSeconds)); + } + else + { + strategy = new FixedWindowRule(1, TimeSpan.FromSeconds(10), _repo); + } + + bool allowed = strategy.IsRequestAllowed(clientToken); + + if (!allowed) + { + context.Result = new ContentResult + { + StatusCodes = 429, + Content = "Too Many Requests - rate limit exceeded." + }; + + return; + } + + await next(); + } + } +} diff --git a/RateLimiter/CompositeRateLimitStrategy.cs b/RateLimiter/CompositeRateLimitStrategy.cs new file mode 100644 index 00000000..1b1e1e7f --- /dev/null +++ b/RateLimiter/CompositeRateLimitStrategy.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using RateLimiter.Interfaces; + +namespace RateLimiter +{ + public class CompositeRateLimitStrategy(IEnumerable strategies) : IRateLimitStrategy + { + public bool IsRequestAllowed(string? clientToken) + { + foreach (var strategy in strategies) + { + if (!strategy.IsRequestAllowed(clientToken)) return false; + } + + return true; + } + } +} diff --git a/RateLimiter/InMemoryUsageRepository.cs b/RateLimiter/InMemoryUsageRepository.cs new file mode 100644 index 00000000..32dd6094 --- /dev/null +++ b/RateLimiter/InMemoryUsageRepository.cs @@ -0,0 +1,22 @@ +using System.Collections.Concurrent; +using RateLimiter.Interfaces; + +namespace RateLimiter +{ + public class InMemoryUsageRepository : IUsageRepository + { + private readonly ConcurrentDictionary _store = new ConcurrentDictionary(); + + public RequestUsage GetUsageForClient(string clientToken) + { + _store.TryGetValue(clientToken, out var usage); + + return usage ?? new RequestUsage(); + } + + public void UpdateUsageForClient(string clientToken, RequestUsage usage) + { + _store[clientToken] = usage; + } + } +} diff --git a/RateLimiter/Interfaces/IRateLimitStrategy.cs b/RateLimiter/Interfaces/IRateLimitStrategy.cs new file mode 100644 index 00000000..0006ac05 --- /dev/null +++ b/RateLimiter/Interfaces/IRateLimitStrategy.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Interfaces +{ + public interface IRateLimitStrategy + { + bool IsRequestAllowed(string? clientToken); + } +} diff --git a/RateLimiter/Interfaces/IUsageRepository.cs b/RateLimiter/Interfaces/IUsageRepository.cs new file mode 100644 index 00000000..3bfe70ec --- /dev/null +++ b/RateLimiter/Interfaces/IUsageRepository.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Interfaces +{ + public interface IUsageRepository + { + public RequestUsage GetUsageForClient(string clientToken); + public void UpdateUsageForClient(string clientToken, RequestUsage usage); + + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..20d96418 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,7 @@ latest enable + + + \ No newline at end of file diff --git a/RateLimiter/RequestUsage.cs b/RateLimiter/RequestUsage.cs new file mode 100644 index 00000000..569a928a --- /dev/null +++ b/RateLimiter/RequestUsage.cs @@ -0,0 +1,11 @@ +using System; + +namespace RateLimiter +{ + public class RequestUsage + { + public int RequestCount { get; set; } + public DateTime WindowStart { get; set; } + public DateTime LastRequestTime { get; set; } + } +} diff --git a/RateLimiter/Rules/CooldownRule.cs b/RateLimiter/Rules/CooldownRule.cs new file mode 100644 index 00000000..e0b68d6e --- /dev/null +++ b/RateLimiter/Rules/CooldownRule.cs @@ -0,0 +1,39 @@ +using System; +using RateLimiter.Interfaces; + +namespace RateLimiter.Rules +{ + + public class CooldownRule : IRateLimitStrategy + { + private readonly IUsageRepository _usageRepository; + private readonly TimeSpan _cooldown; + + public CooldownRule(IUsageRepository usageRepository, TimeSpan cooldown) + { + _usageRepository = usageRepository; + _cooldown = cooldown; + } + + public bool IsRequestAllowed(string? clientToken) + { + var usage = _usageRepository.GetUsageForClient(clientToken); + var now = DateTime.UtcNow; + + // if no prior usage (request count = 0), or if enough time has passed since last request, + // then allow this request + if (usage.RequestCount == 0 || now - usage.LastRequestTime >= _cooldown) + { + usage.RequestCount++; + usage.LastRequestTime = now; + _usageRepository.UpdateUsageForClient(clientToken, usage); + return true; + } + else + { + return false; + } + + } + } +} diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..0cb93b6f --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -0,0 +1,42 @@ +using System; +using RateLimiter.Interfaces; + +namespace RateLimiter.Rules +{ + public class FixedWindowRule : IRateLimitStrategy + { + private readonly int _limit; + private readonly TimeSpan _window; + private readonly IUsageRepository _usageRepository; + + public FixedWindowRule(int limit, TimeSpan window, IUsageRepository usageRepository) + { + _limit = limit; + _window = window; + _usageRepository = usageRepository; + } + + public bool IsRequestAllowed(string? clientToken) + { + var usage = _usageRepository.GetUsageForClient(clientToken); + var now = DateTime.UtcNow; + + if (now - usage.WindowStart > _window) + { + usage.RequestCount = 0; + usage.WindowStart = now; + } + + if (usage.RequestCount < _limit) + { + usage.RequestCount++; + //usage.WindowStart = DateTime.UtcNow; + _usageRepository.UpdateUsageForClient(clientToken, usage); + + return true; + } + + return false; + } + } +} diff --git a/RateLimiter/Rules/GlobalFixedWindowRule.cs b/RateLimiter/Rules/GlobalFixedWindowRule.cs new file mode 100644 index 00000000..1d835ede --- /dev/null +++ b/RateLimiter/Rules/GlobalFixedWindowRule.cs @@ -0,0 +1,43 @@ +using System; +using RateLimiter.Interfaces; + +namespace RateLimiter +{ + public class GlobalFixedWindowRule : IRateLimitStrategy + { + private readonly int _limit; + private readonly TimeSpan _window; + private static readonly object _lock = new object(); + + private int _requestCount = 0; + private DateTime _windowStart = DateTime.UtcNow; + + public GlobalFixedWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public bool IsRequestAllowed(string? clientToken) + { + lock (_lock) + { + var now = DateTime.UtcNow; + + if ((now - _windowStart) > _window) + { + _windowStart = now; + _requestCount = 0; + } + + if (_requestCount < _limit) + { + _requestCount++; + return true; + } + + return false; + } + } + } +}