Skip to content

Commit 42ad95f

Browse files
authored
Merge pull request #269 from nanotaboada/feature/rate-limiting
feat: rate limiting configuration with IP-based partitioning
2 parents 3abc021 + b63e916 commit 42ad95f

File tree

8 files changed

+161
-41
lines changed

8 files changed

+161
-41
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace Dotnet.Samples.AspNetCore.WebApi.Configurations;
2+
3+
/// <summary>
4+
/// Configuration options for the Fixed Window Rate Limiter.
5+
/// </summary>
6+
public class RateLimiterConfiguration
7+
{
8+
/// <summary>
9+
/// Gets or sets the maximum number of permits that can be leased per window.
10+
/// Default value is 60 requests.
11+
/// </summary>
12+
public int PermitLimit { get; set; } = 60;
13+
14+
/// <summary>
15+
/// Gets or sets the time window in seconds for rate limiting.
16+
/// Default value is 60 seconds (1 minute).
17+
/// </summary>
18+
public int WindowSeconds { get; set; } = 60;
19+
20+
/// <summary>
21+
/// Gets or sets the maximum number of requests that can be queued when the permit limit is exceeded.
22+
/// A value of 0 means no queuing (default).
23+
/// </summary>
24+
public int QueueLimit { get; set; } = 0;
25+
}

src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,8 @@
88
</PropertyGroup>
99

1010
<ItemGroup Label="Development dependencies">
11-
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
12-
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0">
13-
<PrivateAssets>all</PrivateAssets>
14-
</PackageReference>
15-
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
16-
<PrivateAssets>all</PrivateAssets>
17-
</PackageReference>
11+
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" PrivateAssets="all" />
12+
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6" PrivateAssets="all" />
1813
</ItemGroup>
1914

2015
<ItemGroup Label="Runtime dependencies">
@@ -23,6 +18,7 @@
2318
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.6" />
2419
<PackageReference Include="AutoMapper" Version="14.0.0" />
2520
<PackageReference Include="FluentValidation" Version="12.0.0" />
21+
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
2622
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
2723
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
2824
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />

src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
namespace Dotnet.Samples.AspNetCore.WebApi.Extensions;
1515

1616
/// <summary>
17-
/// Extension methods for WebApplicationBuilder to encapsulate service configuration.
17+
/// Extension methods for IServiceCollection to encapsulate service configuration.
1818
/// </summary>
19-
public static class ServiceCollectionExtensions
19+
public static partial class ServiceCollectionExtensions
2020
{
2121
/// <summary>
2222
/// Adds DbContextPool with SQLite configuration for PlayerDbContext.
@@ -54,16 +54,25 @@ IWebHostEnvironment environment
5454
/// <see href="https://learn.microsoft.com/en-us/aspnet/core/security/cors"/>
5555
/// </summary>
5656
/// <param name="services">The IServiceCollection instance.</param>
57+
/// <param name="environment">The web host environment.</param>
5758
/// <returns>The IServiceCollection for method chaining.</returns>
58-
public static IServiceCollection AddCorsDefaultPolicy(this IServiceCollection services)
59+
public static IServiceCollection AddCorsDefaultPolicy(
60+
this IServiceCollection services,
61+
IWebHostEnvironment environment
62+
)
5963
{
60-
services.AddCors(options =>
64+
if (environment.IsDevelopment())
6165
{
62-
options.AddDefaultPolicy(corsBuilder =>
66+
services.AddCors(options =>
6367
{
64-
corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
68+
options.AddDefaultPolicy(corsBuilder =>
69+
{
70+
corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
71+
});
6572
});
66-
});
73+
}
74+
75+
// No CORS configured in Production or other environments
6776

6877
return services;
6978
}
@@ -96,7 +105,9 @@ IConfiguration configuration
96105
{
97106
services.AddSwaggerGen(options =>
98107
{
99-
options.SwaggerDoc("v1", configuration.GetSection("OpenApiInfo").Get<OpenApiInfo>());
108+
var openApiInfo = configuration.GetSection("OpenApiInfo").Get<OpenApiInfo>();
109+
110+
options.SwaggerDoc("v1", openApiInfo);
100111
options.IncludeXmlComments(SwaggerUtilities.ConfigureXmlCommentsFilePath());
101112
options.AddSecurityDefinition("Bearer", SwaggerUtilities.ConfigureSecurityDefinition());
102113
options.OperationFilter<AuthorizeCheckOperationFilter>();
@@ -143,4 +154,47 @@ public static IServiceCollection RegisterPlayerRepository(this IServiceCollectio
143154
services.AddScoped<IPlayerRepository, PlayerRepository>();
144155
return services;
145156
}
157+
158+
/// <summary>
159+
/// Adds rate limiting configuration with IP-based partitioning.
160+
/// <br />
161+
/// <see href="https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit"/>
162+
/// </summary>
163+
/// <param name="services">The IServiceCollection instance.</param>
164+
/// <param name="configuration">The application configuration instance.</param>
165+
/// <returns>The IServiceCollection for method chaining.</returns>
166+
public static IServiceCollection AddFixedWindowRateLimiter(
167+
this IServiceCollection services,
168+
IConfiguration configuration
169+
)
170+
{
171+
var settings =
172+
configuration.GetSection("RateLimiter").Get<RateLimiterConfiguration>()
173+
?? new RateLimiterConfiguration();
174+
175+
services.AddRateLimiter(options =>
176+
{
177+
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
178+
httpContext =>
179+
{
180+
var partitionKey = HttpContextUtilities.ExtractIpAddress(httpContext);
181+
182+
return RateLimitPartition.GetFixedWindowLimiter(
183+
partitionKey: partitionKey,
184+
factory: _ => new FixedWindowRateLimiterOptions
185+
{
186+
PermitLimit = settings.PermitLimit,
187+
Window = TimeSpan.FromSeconds(settings.WindowSeconds),
188+
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
189+
QueueLimit = settings.QueueLimit
190+
}
191+
);
192+
}
193+
);
194+
195+
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
196+
});
197+
198+
return services;
199+
}
146200
}

src/Dotnet.Samples.AspNetCore.WebApi/Program.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@
2222

2323
/* Controllers -------------------------------------------------------------- */
2424

25-
builder.Services.AddControllers();
26-
builder.Services.AddCorsDefaultPolicy();
2725
builder.Services.AddHealthChecks();
26+
builder.Services.AddControllers();
2827
builder.Services.AddValidators();
28+
builder.Services.AddCorsDefaultPolicy(builder.Environment);
29+
builder.Services.AddFixedWindowRateLimiter(builder.Configuration);
2930

3031
if (builder.Environment.IsDevelopment())
3132
{
@@ -54,17 +55,16 @@
5455
* -------------------------------------------------------------------------- */
5556

5657
app.UseSerilogRequestLogging();
58+
app.UseHttpsRedirection();
59+
app.MapHealthChecks("/health");
60+
app.UseRateLimiter();
61+
app.MapControllers();
5762

5863
if (app.Environment.IsDevelopment())
5964
{
65+
app.UseCors();
6066
app.UseSwagger();
6167
app.UseSwaggerUI();
6268
}
6369

64-
app.UseHttpsRedirection();
65-
app.UseCors();
66-
app.UseRateLimiter();
67-
app.MapHealthChecks("/health");
68-
app.MapControllers();
69-
7070
await app.RunAsync();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System.Net;
2+
3+
namespace Dotnet.Samples.AspNetCore.WebApi.Utilities;
4+
5+
/// <summary>
6+
/// Utility class for HTTP context operations.
7+
/// </summary>
8+
public static class HttpContextUtilities
9+
{
10+
/// <summary>
11+
/// This method checks for the "X-Forwarded-For" and "X-Real-IP" headers,
12+
/// which are commonly used by proxies to forward the original client IP address.
13+
/// If these headers are not present or the IP address cannot be parsed,
14+
/// it falls back to the remote IP address from the connection.
15+
/// If no valid IP address can be determined, it returns "unknown".
16+
/// </summary>
17+
/// <param name="httpContext">The HTTP context.</param>
18+
/// <returns>The client IP address or "unknown" if not available.</returns>
19+
public static string ExtractIpAddress(HttpContext httpContext)
20+
{
21+
ArgumentNullException.ThrowIfNull(httpContext);
22+
23+
var headers = httpContext.Request.Headers;
24+
IPAddress? ipAddress;
25+
26+
if (headers.TryGetValue("X-Forwarded-For", out var xForwardedFor))
27+
{
28+
var clientIp = xForwardedFor
29+
.ToString()
30+
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
31+
.FirstOrDefault();
32+
33+
if (!string.IsNullOrWhiteSpace(clientIp) && IPAddress.TryParse(clientIp, out ipAddress))
34+
return ipAddress.ToString();
35+
}
36+
37+
if (
38+
headers.TryGetValue("X-Real-IP", out var xRealIp)
39+
&& IPAddress.TryParse(xRealIp.ToString(), out ipAddress)
40+
)
41+
{
42+
return ipAddress.ToString();
43+
}
44+
45+
return httpContext.Connection.RemoteIpAddress?.ToString() ?? $"unknown-{Guid.NewGuid()}";
46+
}
47+
}

src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,10 @@
3333
"Name": "MIT License",
3434
"Url": "https://opensource.org/license/mit"
3535
}
36+
},
37+
"RateLimiter": {
38+
"PermitLimit": 60,
39+
"WindowSeconds": 60,
40+
"QueueLimit": 0
3641
}
3742
}

src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,10 @@
3333
"Name": "MIT License",
3434
"Url": "https://opensource.org/license/mit"
3535
}
36+
},
37+
"RateLimiter": {
38+
"PermitLimit": 60,
39+
"WindowSeconds": 60,
40+
"QueueLimit": 0
3641
}
3742
}

test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,12 @@
99
</PropertyGroup>
1010

1111
<ItemGroup Label="Test dependencies">
12-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1">
13-
<PrivateAssets>all</PrivateAssets>
14-
</PackageReference>
15-
<PackageReference Include="Moq" Version="4.20.72">
16-
<PrivateAssets>all</PrivateAssets>
17-
</PackageReference>
18-
<PackageReference Include="FluentAssertions" Version="8.3.0">
19-
<PrivateAssets>all</PrivateAssets>
20-
</PackageReference>
21-
<PackageReference Include="xunit" Version="2.9.3">
22-
<PrivateAssets>all</PrivateAssets>
23-
</PackageReference>
24-
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
25-
<PrivateAssets>all</PrivateAssets>
26-
</PackageReference>
27-
<PackageReference Include="coverlet.collector" Version="6.0.4">
28-
<PrivateAssets>all</PrivateAssets>
29-
</PackageReference>
12+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" PrivateAssets="all" />
13+
<PackageReference Include="Moq" Version="4.20.72" PrivateAssets="all" />
14+
<PackageReference Include="FluentAssertions" Version="8.3.0" PrivateAssets="all" />
15+
<PackageReference Include="xunit" Version="2.9.3" PrivateAssets="all" />
16+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" PrivateAssets="all" />
17+
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
3018
</ItemGroup>
3119

3220
<ItemGroup>

0 commit comments

Comments
 (0)