diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs
new file mode 100644
index 0000000..a4443db
--- /dev/null
+++ b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs
@@ -0,0 +1,25 @@
+namespace Dotnet.Samples.AspNetCore.WebApi.Configurations;
+
+///
+/// Configuration options for the Fixed Window Rate Limiter.
+///
+public class RateLimiterConfiguration
+{
+ ///
+ /// Gets or sets the maximum number of permits that can be leased per window.
+ /// Default value is 60 requests.
+ ///
+ public int PermitLimit { get; set; } = 60;
+
+ ///
+ /// Gets or sets the time window in seconds for rate limiting.
+ /// Default value is 60 seconds (1 minute).
+ ///
+ public int WindowSeconds { get; set; } = 60;
+
+ ///
+ /// Gets or sets the maximum number of requests that can be queued when the permit limit is exceeded.
+ /// A value of 0 means no queuing (default).
+ ///
+ public int QueueLimit { get; set; } = 0;
+}
diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj
index 522de4b..4cfe51f 100644
--- a/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj
+++ b/src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj
@@ -8,13 +8,8 @@
-
-
- all
-
-
- all
-
+
+
@@ -23,6 +18,7 @@
+
diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs
index 19c05bf..59924c9 100644
--- a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs
@@ -14,9 +14,9 @@
namespace Dotnet.Samples.AspNetCore.WebApi.Extensions;
///
-/// Extension methods for WebApplicationBuilder to encapsulate service configuration.
+/// Extension methods for IServiceCollection to encapsulate service configuration.
///
-public static class ServiceCollectionExtensions
+public static partial class ServiceCollectionExtensions
{
///
/// Adds DbContextPool with SQLite configuration for PlayerDbContext.
@@ -54,16 +54,25 @@ IWebHostEnvironment environment
///
///
/// The IServiceCollection instance.
+ /// The web host environment.
/// The IServiceCollection for method chaining.
- public static IServiceCollection AddCorsDefaultPolicy(this IServiceCollection services)
+ public static IServiceCollection AddCorsDefaultPolicy(
+ this IServiceCollection services,
+ IWebHostEnvironment environment
+ )
{
- services.AddCors(options =>
+ if (environment.IsDevelopment())
{
- options.AddDefaultPolicy(corsBuilder =>
+ services.AddCors(options =>
{
- corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
+ options.AddDefaultPolicy(corsBuilder =>
+ {
+ corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
+ });
});
- });
+ }
+
+ // No CORS configured in Production or other environments
return services;
}
@@ -96,7 +105,9 @@ IConfiguration configuration
{
services.AddSwaggerGen(options =>
{
- options.SwaggerDoc("v1", configuration.GetSection("OpenApiInfo").Get());
+ var openApiInfo = configuration.GetSection("OpenApiInfo").Get();
+
+ options.SwaggerDoc("v1", openApiInfo);
options.IncludeXmlComments(SwaggerUtilities.ConfigureXmlCommentsFilePath());
options.AddSecurityDefinition("Bearer", SwaggerUtilities.ConfigureSecurityDefinition());
options.OperationFilter();
@@ -143,4 +154,47 @@ public static IServiceCollection RegisterPlayerRepository(this IServiceCollectio
services.AddScoped();
return services;
}
+
+ ///
+ /// Adds rate limiting configuration with IP-based partitioning.
+ ///
+ ///
+ ///
+ /// The IServiceCollection instance.
+ /// The application configuration instance.
+ /// The IServiceCollection for method chaining.
+ public static IServiceCollection AddFixedWindowRateLimiter(
+ this IServiceCollection services,
+ IConfiguration configuration
+ )
+ {
+ var settings =
+ configuration.GetSection("RateLimiter").Get()
+ ?? new RateLimiterConfiguration();
+
+ services.AddRateLimiter(options =>
+ {
+ options.GlobalLimiter = PartitionedRateLimiter.Create(
+ httpContext =>
+ {
+ var partitionKey = HttpContextUtilities.ExtractIpAddress(httpContext);
+
+ return RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: partitionKey,
+ factory: _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = settings.PermitLimit,
+ Window = TimeSpan.FromSeconds(settings.WindowSeconds),
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ QueueLimit = settings.QueueLimit
+ }
+ );
+ }
+ );
+
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ });
+
+ return services;
+ }
}
diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs
index cd3bdbc..4b43640 100644
--- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs
+++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs
@@ -22,10 +22,11 @@
/* Controllers -------------------------------------------------------------- */
-builder.Services.AddControllers();
-builder.Services.AddCorsDefaultPolicy();
builder.Services.AddHealthChecks();
+builder.Services.AddControllers();
builder.Services.AddValidators();
+builder.Services.AddCorsDefaultPolicy(builder.Environment);
+builder.Services.AddFixedWindowRateLimiter(builder.Configuration);
if (builder.Environment.IsDevelopment())
{
@@ -54,17 +55,16 @@
* -------------------------------------------------------------------------- */
app.UseSerilogRequestLogging();
+app.UseHttpsRedirection();
+app.MapHealthChecks("/health");
+app.UseRateLimiter();
+app.MapControllers();
if (app.Environment.IsDevelopment())
{
+ app.UseCors();
app.UseSwagger();
app.UseSwaggerUI();
}
-app.UseHttpsRedirection();
-app.UseCors();
-app.UseRateLimiter();
-app.MapHealthChecks("/health");
-app.MapControllers();
-
await app.RunAsync();
diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs
new file mode 100644
index 0000000..e76cfce
--- /dev/null
+++ b/src/Dotnet.Samples.AspNetCore.WebApi/Utilities/HttpContextUtilities.cs
@@ -0,0 +1,47 @@
+using System.Net;
+
+namespace Dotnet.Samples.AspNetCore.WebApi.Utilities;
+
+///
+/// Utility class for HTTP context operations.
+///
+public static class HttpContextUtilities
+{
+ ///
+ /// This method checks for the "X-Forwarded-For" and "X-Real-IP" headers,
+ /// which are commonly used by proxies to forward the original client IP address.
+ /// If these headers are not present or the IP address cannot be parsed,
+ /// it falls back to the remote IP address from the connection.
+ /// If no valid IP address can be determined, it returns "unknown".
+ ///
+ /// The HTTP context.
+ /// The client IP address or "unknown" if not available.
+ public static string ExtractIpAddress(HttpContext httpContext)
+ {
+ ArgumentNullException.ThrowIfNull(httpContext);
+
+ var headers = httpContext.Request.Headers;
+ IPAddress? ipAddress;
+
+ if (headers.TryGetValue("X-Forwarded-For", out var xForwardedFor))
+ {
+ var clientIp = xForwardedFor
+ .ToString()
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .FirstOrDefault();
+
+ if (!string.IsNullOrWhiteSpace(clientIp) && IPAddress.TryParse(clientIp, out ipAddress))
+ return ipAddress.ToString();
+ }
+
+ if (
+ headers.TryGetValue("X-Real-IP", out var xRealIp)
+ && IPAddress.TryParse(xRealIp.ToString(), out ipAddress)
+ )
+ {
+ return ipAddress.ToString();
+ }
+
+ return httpContext.Connection.RemoteIpAddress?.ToString() ?? $"unknown-{Guid.NewGuid()}";
+ }
+}
diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json
index 2e8c6f6..b749dbc 100644
--- a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json
+++ b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.Development.json
@@ -33,5 +33,10 @@
"Name": "MIT License",
"Url": "https://opensource.org/license/mit"
}
+ },
+ "RateLimiter": {
+ "PermitLimit": 60,
+ "WindowSeconds": 60,
+ "QueueLimit": 0
}
}
diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json
index 2e8c6f6..b749dbc 100644
--- a/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json
+++ b/src/Dotnet.Samples.AspNetCore.WebApi/appsettings.json
@@ -33,5 +33,10 @@
"Name": "MIT License",
"Url": "https://opensource.org/license/mit"
}
+ },
+ "RateLimiter": {
+ "PermitLimit": 60,
+ "WindowSeconds": 60,
+ "QueueLimit": 0
}
}
diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj
index d6c085f..be829ae 100644
--- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj
+++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj
@@ -9,24 +9,12 @@
-
- all
-
-
- all
-
-
- all
-
-
- all
-
-
- all
-
-
- all
-
+
+
+
+
+
+