diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/CustomerControllerConcurrencyLiveTests.cs b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerConcurrencyLiveTests.cs similarity index 98% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/CustomerControllerConcurrencyLiveTests.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerConcurrencyLiveTests.cs index 220eb6376a..6c7556bb28 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/CustomerControllerConcurrencyLiveTests.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerConcurrencyLiveTests.cs @@ -5,7 +5,7 @@ using System.Net; using Xunit.Abstractions; -namespace RateLimitingDotNET8.Tests; +namespace RateLimiting.Tests; public class CustomerControllerConcurrencyLiveTests : IClassFixture> { diff --git a/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerPolicyTests.cs b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerPolicyTests.cs new file mode 100644 index 0000000000..f391a9b9ce --- /dev/null +++ b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerPolicyTests.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using System.Net; + +namespace RateLimiting.Tests; + +public class CustomerControllerPolicyTests : IClassFixture> +{ + private const int FixedPermitLimit = 20; + private const int SlidingPermitLimit = 10; + private const int TokenLimit = 10; + + private readonly HttpClient _client; + + public CustomerControllerPolicyTests(WebApplicationFactory factory) + { + _client = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddAuthentication(defaultScheme: "TestScheme") + .AddScheme( + "TestScheme", options => { }); + }); + }) + .CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + } + + [Fact] + public async Task WhenActionHasNoAttribute_ThenTheControllerPolicyLimitsIt() + { + for (var i = 0; i < FixedPermitLimit; i++) + { + var allowed = await _client.GetAsync("/Customer/Index"); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + } + + var rejected = await _client.GetAsync("/Customer/Index"); + + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + } + + [Fact] + public async Task WhenActionHasItsOwnAttribute_ThenItOverridesTheControllerPolicy() + { + // The controller carries [EnableRateLimiting(Policies.Fixed)] at 20 permits, + // the action carries [EnableRateLimiting(Policies.Sliding)] at 10. If the + // controller's policy were the one in force, request 11 would still be allowed. + for (var i = 0; i < SlidingPermitLimit; i++) + { + var allowed = await _client.GetAsync("/Customer/Details"); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + } + + var rejected = await _client.GetAsync("/Customer/Details"); + + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + } + + [Fact] + public async Task WhenATokenBucketRequestIsRejected_ThenOnRejectedWritesRetryAfter() + { + HttpResponseMessage? rejected = null; + + for (var i = 0; i <= TokenLimit && rejected is null; i++) + { + var response = await _client.GetAsync("/Customer/GetById"); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + rejected = response; + } + } + + Assert.NotNull(rejected); + Assert.True(rejected!.Headers.TryGetValues("Retry-After", out var values)); + Assert.True(int.Parse(values!.Single()) > 0); + } +} diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/CustomerControllerTests.cs b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerTests.cs similarity index 98% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/CustomerControllerTests.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerTests.cs index 636d4c9457..2f961e4bf9 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/CustomerControllerTests.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/CustomerControllerTests.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using System.Net; -namespace RateLimitingDotNET8.Tests; +namespace RateLimiting.Tests; public class CustomerControllerTests : IClassFixture> { private readonly HttpClient _client; diff --git a/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/GlobalLimiterTests.cs b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/GlobalLimiterTests.cs new file mode 100644 index 0000000000..662fec070b --- /dev/null +++ b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/GlobalLimiterTests.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Threading.RateLimiting; + +namespace RateLimiting.Tests; + +public class GlobalLimiterTests : IClassFixture> +{ + private const int GlobalPermitLimit = 5; + + private readonly HttpClient _client; + + public GlobalLimiterTests(WebApplicationFactory factory) + { + _client = factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddAuthentication(defaultScheme: "TestScheme") + .AddScheme( + "TestScheme", options => { }); + + // Repeated AddRateLimiter calls configure the same RateLimiterOptions + // instance, so this global limiter is added to the ones Program.cs registers. + services.AddRateLimiter(limiterOptions => + { + limiterOptions.GlobalLimiter = PartitionedRateLimiter.Create( + _ => RateLimitPartition.GetFixedWindowLimiter("global", _ => + new FixedWindowRateLimiterOptions + { + PermitLimit = GlobalPermitLimit, + Window = TimeSpan.FromMinutes(1) + })); + }); + }); + }) + .CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + } + + [Fact] + public async Task WhenTheGlobalLimitIsSpent_ThenAnEndpointInsideItsOwnLimitIsStillRejected() + { + // /Customer/Index is under the controller's fixed policy at 20 permits, so + // anything rejected inside the first six requests came from the global limiter. + for (var i = 0; i < GlobalPermitLimit; i++) + { + var allowed = await _client.GetAsync("/Customer/Index"); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + } + + var rejected = await _client.GetAsync("/Customer/Index"); + + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + } + + [Fact] + public async Task WhenAnActionDisablesRateLimiting_ThenTheGlobalLimiterDoesNotApply() + { + for (var i = 0; i < GlobalPermitLimit * 2; i++) + { + var response = await _client.GetAsync("/Customer/SpecialOffer"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } +} diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/GlobalUsings.cs b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/GlobalUsings.cs similarity index 100% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/GlobalUsings.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting.Tests/GlobalUsings.cs diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/RateLimitingDotNET8.Tests.csproj b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/RateLimiting.Tests.csproj similarity index 73% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/RateLimitingDotNET8.Tests.csproj rename to aspnetcore-webapi/RateLimiting/RateLimiting.Tests/RateLimiting.Tests.csproj index 29769604fb..195fb5c683 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/RateLimitingDotNET8.Tests.csproj +++ b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/RateLimiting.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -10,21 +10,21 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/TestAuthHandler.cs b/aspnetcore-webapi/RateLimiting/RateLimiting.Tests/TestAuthHandler.cs similarity index 100% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.Tests/TestAuthHandler.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting.Tests/TestAuthHandler.cs diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.sln b/aspnetcore-webapi/RateLimiting/RateLimiting.sln similarity index 87% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.sln rename to aspnetcore-webapi/RateLimiting/RateLimiting.sln index 46495d885a..49152fff98 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8.sln +++ b/aspnetcore-webapi/RateLimiting/RateLimiting.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.8.34309.116 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimitingDotNET8", "RateLimitingDotNET8\RateLimitingDotNET8.csproj", "{080CC0E5-4B65-41EB-A34A-D810C414820D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimiting", "RateLimiting\RateLimiting.csproj", "{080CC0E5-4B65-41EB-A34A-D810C414820D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimitingDotNET8.Tests", "RateLimitingDotNET8.Tests\RateLimitingDotNET8.Tests.csproj", "{B8FEBE55-F1D9-49FB-ADDE-2B456A195DB1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiting.Tests", "RateLimiting.Tests\RateLimiting.Tests.csproj", "{B8FEBE55-F1D9-49FB-ADDE-2B456A195DB1}" ProjectSection(ProjectDependencies) = postProject {080CC0E5-4B65-41EB-A34A-D810C414820D} = {080CC0E5-4B65-41EB-A34A-D810C414820D} EndProjectSection diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/ConfigurationSettingsExtension.cs b/aspnetcore-webapi/RateLimiting/RateLimiting/ConfigurationSettingsExtension.cs similarity index 96% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/ConfigurationSettingsExtension.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting/ConfigurationSettingsExtension.cs index cfce50f8c9..818f1bb662 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/ConfigurationSettingsExtension.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting/ConfigurationSettingsExtension.cs @@ -1,4 +1,4 @@ -namespace RateLimitingDotNET8; +namespace RateLimiting; public static class ConfigurationSettingsExtension { diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Controllers/CustomerController.cs b/aspnetcore-webapi/RateLimiting/RateLimiting/Controllers/CustomerController.cs similarity index 90% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Controllers/CustomerController.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting/Controllers/CustomerController.cs index c4f4dc499c..244a2df85c 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Controllers/CustomerController.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting/Controllers/CustomerController.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -namespace RateLimitingDotNET8.Controllers; +namespace RateLimiting.Controllers; -//[EnableRateLimiting(Policies.Fixed)] +[EnableRateLimiting(Policies.Fixed)] [ApiController] [Route("customer")] public class CustomerController : ControllerBase diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimiterOptions.cs b/aspnetcore-webapi/RateLimiting/RateLimiting/LimiterSettings.cs similarity index 82% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimiterOptions.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting/LimiterSettings.cs index c36eb933f1..4699c6a90e 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimiterOptions.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting/LimiterSettings.cs @@ -1,18 +1,18 @@ -namespace RateLimitingDotNET8; +namespace RateLimiting; -public class RateLimiterOptions +public class LimiterSettings { public int QueueLimit { get; set; } = default!; } -public class FixedOptions : RateLimiterOptions +public class FixedOptions : LimiterSettings { public const string Fixed = "FixedOptions"; public int PermitLimit { get; set; } = default!; public double Window { get; set; } = default!; } -public class SlidingWindowOptions : RateLimiterOptions +public class SlidingWindowOptions : LimiterSettings { public const string Sliding = "SlidingWindowOptions"; @@ -21,7 +21,7 @@ public class SlidingWindowOptions : RateLimiterOptions public int SegmentsPerWindow { get; set; } = default!; } -public class TokenBucketOptions : RateLimiterOptions +public class TokenBucketOptions : LimiterSettings { public const string Token = "TokenBucketOptions"; @@ -31,7 +31,7 @@ public class TokenBucketOptions : RateLimiterOptions public bool AutoReplenishment { get; set; } = default!; } -public class ConcurrencyOptions : RateLimiterOptions +public class ConcurrencyOptions : LimiterSettings { public const string Concurrency = "ConcurrencyOptions"; diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Policies.cs b/aspnetcore-webapi/RateLimiting/RateLimiting/Policies.cs similarity index 90% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Policies.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting/Policies.cs index 7705aaf7d2..af63cd0f73 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Policies.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting/Policies.cs @@ -1,4 +1,4 @@ -namespace RateLimitingDotNET8; +namespace RateLimiting; public static class Policies { diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Program.cs b/aspnetcore-webapi/RateLimiting/RateLimiting/Program.cs similarity index 92% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Program.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting/Program.cs index 0fee478d14..0ff661798e 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Program.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting/Program.cs @@ -1,9 +1,11 @@ -using RateLimitingDotNET8; +using RateLimiting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSettings(builder.Configuration); +RateLimiters.RejectionHandling(builder); + RateLimiters.FixedRateLimiter(builder); RateLimiters.SlidingRateLimiter(builder); diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Properties/launchSettings.json b/aspnetcore-webapi/RateLimiting/RateLimiting/Properties/launchSettings.json similarity index 100% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/Properties/launchSettings.json rename to aspnetcore-webapi/RateLimiting/RateLimiting/Properties/launchSettings.json diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimiters.cs b/aspnetcore-webapi/RateLimiting/RateLimiting/RateLimiters.cs similarity index 88% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimiters.cs rename to aspnetcore-webapi/RateLimiting/RateLimiting/RateLimiters.cs index 34c6530a33..8e32bc7d8b 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimiters.cs +++ b/aspnetcore-webapi/RateLimiting/RateLimiting/RateLimiters.cs @@ -1,12 +1,31 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Options; +using System.Globalization; using System.Threading.RateLimiting; -namespace RateLimitingDotNET8; +namespace RateLimiting; public static class RateLimiters { + public static void RejectionHandling(WebApplicationBuilder builder) + { + builder.Services.AddRateLimiter(limiterOptions => + { + limiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + limiterOptions.OnRejected = (context, cancellationToken) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + context.HttpContext.Response.Headers.RetryAfter = + ((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo); + } + + return ValueTask.CompletedTask; + }; + }); + } + public static void FixedRateLimiter(WebApplicationBuilder builder) { var fixedOptions = GetOptionValues(builder); @@ -67,7 +86,7 @@ public static void ConcurrencyRateLimiter(WebApplicationBuilder builder) public static void AuthorizationRateLimiter(WebApplicationBuilder builder) { - var authorizedLimiterOptions = builder.Configuration.GetSection(AuthorizedOptions.Authorized).Get(); + var authorizedLimiterOptions = GetOptionValues(builder); var unauthorizedLimiterOptions = GetOptionValues(builder); @@ -76,7 +95,6 @@ public static void AuthorizationRateLimiter(WebApplicationBuilder builder) builder.Services.AddRateLimiter(limiterOptions => { - limiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests; limiterOptions.AddPolicy(policyName: Policies.Authorization, partitioner: httpContext => { var accessToken = httpContext.GetTokenAsync("access_token").Result; @@ -144,9 +162,6 @@ public static void MinimalApiRateLimiting(WebApplication app) } - private static T GetOptionValues(WebApplicationBuilder builder) where T : class - { - var serviceProvider = builder.Services.BuildServiceProvider(); - return serviceProvider.GetRequiredService>().Value; - } + private static T GetOptionValues(WebApplicationBuilder builder) where T : class, new() + => builder.Configuration.GetSection(typeof(T).Name).Get() ?? new T(); } diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimitingDotNET8.csproj b/aspnetcore-webapi/RateLimiting/RateLimiting/RateLimiting.csproj similarity index 81% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimitingDotNET8.csproj rename to aspnetcore-webapi/RateLimiting/RateLimiting/RateLimiting.csproj index 099e4bd480..98a9ed22f4 100644 --- a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/RateLimitingDotNET8.csproj +++ b/aspnetcore-webapi/RateLimiting/RateLimiting/RateLimiting.csproj @@ -1,13 +1,13 @@  - net8.0 + net10.0 enable enable - + diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/appsettings.Development.json b/aspnetcore-webapi/RateLimiting/RateLimiting/appsettings.Development.json similarity index 100% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/appsettings.Development.json rename to aspnetcore-webapi/RateLimiting/RateLimiting/appsettings.Development.json diff --git a/aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/appsettings.json b/aspnetcore-webapi/RateLimiting/RateLimiting/appsettings.json similarity index 100% rename from aspnetcore-webapi/RateLimitingDotNET8/RateLimitingDotNET8/appsettings.json rename to aspnetcore-webapi/RateLimiting/RateLimiting/appsettings.json