Skip to content

Commit 8f19840

Browse files
authored
Merge pull request #303 from Atypical-Consulting/feat/rate-limiting
feat: add rate limiting support
2 parents 4149983 + ef50fd6 commit 8f19840

11 files changed

Lines changed: 408 additions & 8 deletions

docs/configuration.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ The following skeleton shows **every** configuration option at a glance. Require
5555
"roles": ["Admin", "User"], // optional
5656
"generateLoginEndpoint": true, // default: true
5757
"generateRegisterEndpoint": true // default: true
58+
},
59+
"rateLimit": {
60+
"permitLimit": 100, // default: 100
61+
"windowSeconds": 60, // default: 60
62+
"segmentsPerWindow": 6 // default: 6
5863
}
5964
},
6065
"enums": {
@@ -118,7 +123,8 @@ The `config` object contains project-level settings.
118123
"cors": { ... },
119124
"features": { ... },
120125
"database": { ... },
121-
"auth": { ... }
126+
"auth": { ... },
127+
"rateLimit": { ... }
122128
}
123129
}
124130
```
@@ -212,6 +218,36 @@ When auth is enabled:
212218
{: .note }
213219
> When the `auth` block is absent, all endpoints use `AllowAnonymous()` and no auth infrastructure is generated. This is fully backward-compatible.
214220
221+
### Rate Limiting
222+
223+
The optional `rateLimit` object enables ASP.NET Core's built-in sliding window rate limiter for the generated API.
224+
225+
```json
226+
{
227+
"config": {
228+
"rateLimit": {
229+
"permitLimit": 100,
230+
"windowSeconds": 60,
231+
"segmentsPerWindow": 6
232+
}
233+
}
234+
}
235+
```
236+
237+
| Field | Type | Default | Description |
238+
|---|---|---|---|
239+
| `permitLimit` | `integer` | `100` | Maximum number of requests allowed within the time window. |
240+
| `windowSeconds` | `integer` | `60` | Duration of the time window in seconds. |
241+
| `segmentsPerWindow` | `integer` | `6` | Number of segments the window is divided into for smoother rate limiting. |
242+
243+
When rate limiting is enabled:
244+
- Requests exceeding the limit receive a `429 Too Many Requests` response
245+
- The rate limiter middleware is added after CORS and before authentication in the pipeline
246+
- A named policy `"sliding"` is registered and applied globally
247+
248+
{: .note }
249+
> When the `rateLimit` block is absent, no rate limiting infrastructure is generated. This is fully backward-compatible.
250+
215251
## Entities
216252

217253
The `entities` object is a dictionary where each key is an entity name (PascalCase) and each value describes its properties and optional relationships and seed data.
@@ -401,6 +437,11 @@ A full `ninjadog.json` demonstrating all features:
401437
"audience": "bookstore-api",
402438
"tokenExpirationMinutes": 120,
403439
"roles": ["Admin", "Editor"]
440+
},
441+
"rateLimit": {
442+
"permitLimit": 200,
443+
"windowSeconds": 60,
444+
"segmentsPerWindow": 6
404445
}
405446
},
406447
"enums": {

src/library/Ninjadog.Settings/Config/NinjadogConfiguration.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ namespace Ninjadog.Settings.Config;
1717
/// <param name="DatabaseProvider">The database provider to use (sqlite, postgresql, sqlserver). Default is sqlite.</param>
1818
/// <param name="Aot">Indicates whether Native AOT publishing support should be enabled in the generated project. Default is false.</param>
1919
/// <param name="Auth">The optional JWT authentication configuration for the application.</param>
20+
/// <param name="RateLimit">The optional rate limiting configuration for the application.</param>
2021
public abstract record NinjadogConfiguration(
2122
string Name,
2223
string Version,
@@ -29,4 +30,5 @@ public abstract record NinjadogConfiguration(
2930
bool Auditing = false,
3031
string DatabaseProvider = "sqlite",
3132
bool Aot = false,
32-
NinjadogAuthConfiguration? Auth = null);
33+
NinjadogAuthConfiguration? Auth = null,
34+
NinjadogRateLimitConfiguration? RateLimit = null);

src/library/Ninjadog.Settings/Config/NinjadogLoadedConfiguration.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ public sealed record NinjadogLoadedConfiguration(
1515
bool Auditing = false,
1616
string DatabaseProvider = "sqlite",
1717
bool Aot = false,
18-
NinjadogAuthConfiguration? Auth = null)
19-
: NinjadogConfiguration(Name, Version, Description, RootNamespace, OutputPath, SaveGeneratedFiles, Cors, SoftDelete, Auditing, DatabaseProvider, Aot, Auth);
18+
NinjadogAuthConfiguration? Auth = null,
19+
NinjadogRateLimitConfiguration? RateLimit = null)
20+
: NinjadogConfiguration(Name, Version, Description, RootNamespace, OutputPath, SaveGeneratedFiles, Cors, SoftDelete, Auditing, DatabaseProvider, Aot, Auth, RateLimit);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Ninjadog.Settings.Config;
2+
3+
/// <summary>
4+
/// Represents the rate limiting configuration for the generated API.
5+
/// Uses ASP.NET Core's built-in sliding window rate limiter.
6+
/// </summary>
7+
/// <param name="PermitLimit">The maximum number of requests permitted in the window.</param>
8+
/// <param name="WindowSeconds">The time window duration in seconds.</param>
9+
/// <param name="SegmentsPerWindow">The number of segments the window is divided into.</param>
10+
public record NinjadogRateLimitConfiguration(
11+
int PermitLimit = 100,
12+
int WindowSeconds = 60,
13+
int SegmentsPerWindow = 6);

src/library/Ninjadog.Settings/NinjadogSettings.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ public static NinjadogSettings FromJsonString(string json)
9494
auth = new NinjadogAuthConfiguration(authProvider, issuer, audience, tokenExpirationMinutes, roles, generateLoginEndpoint, generateRegisterEndpoint);
9595
}
9696

97+
NinjadogRateLimitConfiguration? rateLimit = null;
98+
if (TryGetOptionalObject(configElement, "rateLimit", out var rateLimitElement))
99+
{
100+
var permitLimit = GetOptionalInt32(rateLimitElement, "permitLimit") ?? 100;
101+
var windowSeconds = GetOptionalInt32(rateLimitElement, "windowSeconds") ?? 60;
102+
var segmentsPerWindow = GetOptionalInt32(rateLimitElement, "segmentsPerWindow") ?? 6;
103+
rateLimit = new NinjadogRateLimitConfiguration(permitLimit, windowSeconds, segmentsPerWindow);
104+
}
105+
97106
var config = new NinjadogLoadedConfiguration(
98107
Name: name,
99108
Version: GetRequiredString(configElement, "version"),
@@ -106,7 +115,8 @@ public static NinjadogSettings FromJsonString(string json)
106115
Auditing: auditing,
107116
DatabaseProvider: databaseProvider,
108117
Aot: aot,
109-
Auth: auth);
118+
Auth: auth,
119+
RateLimit: rateLimit);
110120

111121
var entities = new NinjadogLoadedEntities();
112122

src/templates/Ninjadog.Templates.CrudWebApi/Template/ProgramTemplate.cs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public override NinjadogContentFile GenerateOne(NinjadogSettings ninjadogSetting
1515
var cors = ninjadogSettings.Config.Cors;
1616
var aot = ninjadogSettings.Config.Aot;
1717
var auth = ninjadogSettings.Config.Auth;
18+
var rateLimit = ninjadogSettings.Config.RateLimit;
1819
var hasSeedData = ninjadogSettings.Entities.FromKeys().Any(e => e.SeedData is { Count: > 0 });
1920
const string fileName = "Program.cs";
2021

@@ -28,7 +29,7 @@ public override NinjadogContentFile GenerateOne(NinjadogSettings ninjadogSetting
2829
using {{rootNamespace}};
2930
using {{rootNamespace}}.Database;
3031
using {{rootNamespace}}.Middleware;
31-
{{GenerateAuthUsing(auth, rootNamespace)}}using Serilog;
32+
{{GenerateRateLimitUsing(rateLimit)}}{{GenerateAuthUsing(auth, rootNamespace)}}using Serilog;
3233
3334
Log.Logger = new LoggerConfiguration()
3435
.WriteTo.Console()
@@ -53,15 +54,15 @@ public override NinjadogContentFile GenerateOne(NinjadogSettings ninjadogSetting
5354
{{GenerateCorsPolicy(cors)}}
5455
});
5556
});
56-
{{GenerateAuthServiceRegistration(auth)}}
57+
{{GenerateAuthServiceRegistration(auth)}}{{GenerateRateLimitServiceRegistration(rateLimit)}}
5758
services.AddNinjadog(config);
5859
5960
var app = builder.Build();
6061
6162
app.UseMiddleware<RequestCorrelationMiddleware>();
6263
app.UseSerilogRequestLogging();
6364
app.UseCors(myAllowSpecificOrigins);
64-
{{GenerateAuthMiddleware(auth)}} app.UseNinjadog();
65+
{{GenerateRateLimitMiddleware(rateLimit)}}{{GenerateAuthMiddleware(auth)}} app.UseNinjadog();
6566
6667
await app.Services
6768
.GetRequiredService<DatabaseInitializer>()
@@ -176,6 +177,51 @@ await app.Services
176177
""";
177178
}
178179

180+
private static string GenerateRateLimitUsing(NinjadogRateLimitConfiguration? rateLimit)
181+
{
182+
return rateLimit is null
183+
? string.Empty
184+
: """
185+
using System.Threading.RateLimiting;
186+
using Microsoft.AspNetCore.RateLimiting;
187+
188+
""";
189+
}
190+
191+
private static string GenerateRateLimitServiceRegistration(NinjadogRateLimitConfiguration? rateLimit)
192+
{
193+
if (rateLimit is null)
194+
{
195+
return string.Empty;
196+
}
197+
198+
return $$"""
199+
200+
services.AddRateLimiter(options =>
201+
{
202+
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
203+
options.AddSlidingWindowLimiter("sliding", limiter =>
204+
{
205+
limiter.PermitLimit = {{rateLimit.PermitLimit}};
206+
limiter.Window = TimeSpan.FromSeconds({{rateLimit.WindowSeconds}});
207+
limiter.SegmentsPerWindow = {{rateLimit.SegmentsPerWindow}};
208+
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
209+
limiter.QueueLimit = 0;
210+
});
211+
});
212+
""";
213+
}
214+
215+
private static string GenerateRateLimitMiddleware(NinjadogRateLimitConfiguration? rateLimit)
216+
{
217+
return rateLimit is null
218+
? string.Empty
219+
: """
220+
app.UseRateLimiter();
221+
222+
""";
223+
}
224+
179225
private static string GenerateSeederCall(bool hasSeedData)
180226
{
181227
return !hasSeedData

src/tests/Ninjadog.Tests/Helpers/TestSettings.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,35 @@ public AotSeededEntitiesCollection()
140140
public sealed record AotSeededSettings()
141141
: NinjadogSettings(new AotConfiguration(), new AotSeededEntitiesCollection());
142142

143+
public sealed record RateLimitConfiguration()
144+
: NinjadogConfiguration(
145+
Name: "TestApp",
146+
Version: "1.0.0",
147+
Description: "Test application",
148+
RootNamespace: "TestApp.Api",
149+
OutputPath: "output",
150+
SaveGeneratedFiles: false,
151+
RateLimit: new NinjadogRateLimitConfiguration());
152+
153+
public sealed record RateLimitSettings()
154+
: NinjadogSettings(new RateLimitConfiguration(), new TestEntitiesCollection());
155+
156+
public sealed record CustomRateLimitConfiguration()
157+
: NinjadogConfiguration(
158+
Name: "TestApp",
159+
Version: "1.0.0",
160+
Description: "Test application",
161+
RootNamespace: "TestApp.Api",
162+
OutputPath: "output",
163+
SaveGeneratedFiles: false,
164+
RateLimit: new NinjadogRateLimitConfiguration(
165+
PermitLimit: 50,
166+
WindowSeconds: 30,
167+
SegmentsPerWindow: 3));
168+
169+
public sealed record CustomRateLimitSettings()
170+
: NinjadogSettings(new CustomRateLimitConfiguration(), new TestEntitiesCollection());
171+
143172
public sealed record AuthConfiguration()
144173
: NinjadogConfiguration(
145174
Name: "TestApp",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//------------------------------------------------------------------------------
2+
// This code was powered by the Ninjadog Engine
3+
//
4+
// Developed by: Atypical Consulting SRL - Philippe Matray
5+
// Generated on: [SCRUBBED]
6+
// Version : [SCRUBBED]
7+
//
8+
// This file is part of a custom software solution crafted to meet your
9+
// specific needs. For optimal performance and compatibility, modifications
10+
// should be coordinated with Ninjadog support services.
11+
//------------------------------------------------------------------------------
12+
13+
using TestApp.Api;
14+
using TestApp.Api.Database;
15+
using TestApp.Api.Middleware;
16+
using System.Threading.RateLimiting;
17+
using Microsoft.AspNetCore.RateLimiting;
18+
using Serilog;
19+
20+
Log.Logger = new LoggerConfiguration()
21+
.WriteTo.Console()
22+
.CreateBootstrapLogger();
23+
24+
try
25+
{
26+
const string myAllowSpecificOrigins = "_myAllowSpecificOrigins";
27+
28+
var builder = WebApplication.CreateBuilder(args);
29+
var services = builder.Services;
30+
var config = builder.Configuration;
31+
32+
builder.Host.UseSerilog((context, loggerConfig) =>
33+
loggerConfig.ReadFrom.Configuration(context.Configuration));
34+
35+
services.AddCors(options =>
36+
{
37+
options.AddPolicy(name: myAllowSpecificOrigins,
38+
policy =>
39+
{
40+
policy.WithOrigins("https://localhost:7270");
41+
});
42+
});
43+
44+
services.AddRateLimiter(options =>
45+
{
46+
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
47+
options.AddSlidingWindowLimiter("sliding", limiter =>
48+
{
49+
limiter.PermitLimit = 50;
50+
limiter.Window = TimeSpan.FromSeconds(30);
51+
limiter.SegmentsPerWindow = 3;
52+
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
53+
limiter.QueueLimit = 0;
54+
});
55+
});
56+
services.AddNinjadog(config);
57+
58+
var app = builder.Build();
59+
60+
app.UseMiddleware<RequestCorrelationMiddleware>();
61+
app.UseSerilogRequestLogging();
62+
app.UseCors(myAllowSpecificOrigins);
63+
app.UseRateLimiter();
64+
app.UseNinjadog();
65+
66+
await app.Services
67+
.GetRequiredService<DatabaseInitializer>()
68+
.InitializeAsync()
69+
.ConfigureAwait(false);
70+
71+
app.Run();
72+
}
73+
catch (Exception ex)
74+
{
75+
Log.Fatal(ex, "Application terminated unexpectedly");
76+
}
77+
finally
78+
{
79+
await Log.CloseAndFlushAsync();
80+
}

0 commit comments

Comments
 (0)