From 9a91073f0624afc80ca8191ae966e092d7101ca3 Mon Sep 17 00:00:00 2001 From: Liangxirong Date: Mon, 16 Dec 2024 11:03:31 +0800 Subject: [PATCH] 1.RateLimitRule adds the ConterKeyMode property. Below is the effect of its value. Let's say the EndPoint is set to *:/api/values VerbAndPath (default, compatible with older versions) - GET:/api/values/1 and POST:/api/values/1, GET:/api/values/2 and POST:/api/values/2 will be counted separately. Path-GET :/api/values and POST:/api/values will be combined and counted VerbAndRule-GET :/api/values/1 POST:/api/values/2 will be counted separately, but GET:/api/values/1 and GET:/api/values/2 will be counted together. Rule-GET :/api/values/1, POST:/api/values/1, GET:/api/values/2, POST:/api/values/2 will all be concatenated. 2. Increase ResponseType QuotaExceededResponse, Controller, Action, Url attribute. Depending on the value of ResponseType, different properties are used to respond to different outputs. WriteContent (default, older compatible) - StatusCode writes Content to the response stream based on ContentType. RedirectToAction (must set IApplicationBuilder UseRouting(), IApplicationBuilder. UseEndpoints() first) - redirects to the specified Controller. The Action. QuotaExceededParams quotaExceededParams can be obtained from the parameters to output the results with a more customized response. RedirectToUrl - Redirects to the specified Url. Low custom response, cannot get QuotaExceededParams quotaExceededParams. Suitable for developers who just need to beautification their pages. 3. Add relevant unit tests. --- .../PathCounterKeyBuilder.cs | 11 +- .../Middleware/ClientRateLimitMiddleware.cs | 6 +- .../Middleware/IpRateLimitMiddleware.cs | 6 +- .../Middleware/RateLimitMiddleware.cs | 90 +++++- .../Models/QuotaExceededParams.cs | 33 +++ .../Models/QuotaExceededResponse.cs | 23 +- .../Models/RateLimitRule.cs | 36 +++ .../Controllers/CounterKeyController.cs | 53 ++++ .../Controllers/ValuesController.cs | 2 + test/AspNetCoreRateLimit.Demo/Startup.cs | 8 +- .../AspNetCoreRateLimit.Demo/appsettings.json | 43 ++- .../IpRateLimitTests.cs | 261 ++++++++++++++++++ 12 files changed, 546 insertions(+), 26 deletions(-) create mode 100644 src/AspNetCoreRateLimit/Models/QuotaExceededParams.cs create mode 100644 test/AspNetCoreRateLimit.Demo/Controllers/CounterKeyController.cs diff --git a/src/AspNetCoreRateLimit/CounterKeyBuilders/PathCounterKeyBuilder.cs b/src/AspNetCoreRateLimit/CounterKeyBuilders/PathCounterKeyBuilder.cs index 7c194cdd..faf32cc1 100644 --- a/src/AspNetCoreRateLimit/CounterKeyBuilders/PathCounterKeyBuilder.cs +++ b/src/AspNetCoreRateLimit/CounterKeyBuilders/PathCounterKeyBuilder.cs @@ -4,7 +4,16 @@ public class PathCounterKeyBuilder : ICounterKeyBuilder { public string Build(ClientRequestIdentity requestIdentity, RateLimitRule rule) { - return $"_{requestIdentity.HttpVerb}_{requestIdentity.Path}"; + switch (rule.ConterKeyMode) { + case EPConterKeyMode.VerbAndPath: + return $"_VP_{requestIdentity.HttpVerb}_{requestIdentity.Path}"; + case EPConterKeyMode.Path: + return $"_P_{requestIdentity.Path}"; + case EPConterKeyMode.VerbAndRule: + return $"_VE_{requestIdentity.HttpVerb}_{rule.Endpoint}"; + default: + return $"_E_{rule.Endpoint}"; + } } } } diff --git a/src/AspNetCoreRateLimit/Middleware/ClientRateLimitMiddleware.cs b/src/AspNetCoreRateLimit/Middleware/ClientRateLimitMiddleware.cs index 5cb06e09..b7cb6434 100644 --- a/src/AspNetCoreRateLimit/Middleware/ClientRateLimitMiddleware.cs +++ b/src/AspNetCoreRateLimit/Middleware/ClientRateLimitMiddleware.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,8 +14,9 @@ public ClientRateLimitMiddleware(RequestDelegate next, IOptions options, IClientPolicyStore policyStore, IRateLimitConfiguration config, - ILogger logger) - : base(next, options?.Value, new ClientRateLimitProcessor(options?.Value, policyStore, processingStrategy), config) + ILogger logger, + LinkGenerator linkGenerator) + : base(next, options?.Value, new ClientRateLimitProcessor(options?.Value, policyStore, processingStrategy), config, linkGenerator) { _logger = logger; } diff --git a/src/AspNetCoreRateLimit/Middleware/IpRateLimitMiddleware.cs b/src/AspNetCoreRateLimit/Middleware/IpRateLimitMiddleware.cs index 71834d6c..5bb3561a 100644 --- a/src/AspNetCoreRateLimit/Middleware/IpRateLimitMiddleware.cs +++ b/src/AspNetCoreRateLimit/Middleware/IpRateLimitMiddleware.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,9 +14,10 @@ public IpRateLimitMiddleware(RequestDelegate next, IOptions options, IIpPolicyStore policyStore, IRateLimitConfiguration config, - ILogger logger + ILogger logger, + LinkGenerator linkGenerator ) - : base(next, options?.Value, new IpRateLimitProcessor(options?.Value, policyStore, processingStrategy), config) + : base(next, options?.Value, new IpRateLimitProcessor(options?.Value, policyStore, processingStrategy), config, linkGenerator) { _logger = logger; } diff --git a/src/AspNetCoreRateLimit/Middleware/RateLimitMiddleware.cs b/src/AspNetCoreRateLimit/Middleware/RateLimitMiddleware.cs index 3b6f63fc..426990b0 100644 --- a/src/AspNetCoreRateLimit/Middleware/RateLimitMiddleware.cs +++ b/src/AspNetCoreRateLimit/Middleware/RateLimitMiddleware.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using AspNetCoreRateLimit.Models; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; namespace AspNetCoreRateLimit { @@ -14,17 +16,20 @@ public abstract class RateLimitMiddleware private readonly TProcessor _processor; private readonly RateLimitOptions _options; private readonly IRateLimitConfiguration _config; + private readonly LinkGenerator _linkGenerator; protected RateLimitMiddleware( RequestDelegate next, RateLimitOptions options, TProcessor processor, - IRateLimitConfiguration config) + IRateLimitConfiguration config, + LinkGenerator linkGenerator) { _next = next; _options = options; _processor = processor; _config = config; + _linkGenerator = linkGenerator; _config.RegisterResolvers(); } @@ -168,28 +173,83 @@ public virtual async Task ResolveIdentityAsync(HttpContex public virtual Task ReturnQuotaExceededResponse(HttpContext httpContext, RateLimitRule rule, string retryAfter) { - //Use Endpoint QuotaExceededResponse + + QuotaExceededResponse quotaExceededResponse; + + // Use Endpoint QuotaExceededResponse first, then Global QuotaExceededResponse, then default if (rule.QuotaExceededResponse != null) { - _options.QuotaExceededResponse = rule.QuotaExceededResponse; + quotaExceededResponse = rule.QuotaExceededResponse; + } + else if (_options.QuotaExceededResponse != null) + { + quotaExceededResponse = _options.QuotaExceededResponse; } - var message = string.Format( - _options.QuotaExceededResponse?.Content ?? - _options.QuotaExceededMessage ?? - "API calls quota exceeded! maximum admitted {0} per {1}.", - rule.Limit, - rule.PeriodTimespan.HasValue ? FormatPeriodTimespan(rule.PeriodTimespan.Value) : rule.Period, retryAfter); - if (!_options.DisableRateLimitHeaders) - { - httpContext.Response.Headers["Retry-After"] = retryAfter; + else + { + quotaExceededResponse = new QuotaExceededResponse() + { + Content = "API calls quota exceeded! maximum admitted {0} per {1}.", + ContentType = "text/plain" + }; } - httpContext.Response.StatusCode = _options.QuotaExceededResponse?.StatusCode ?? _options.HttpStatusCode; - httpContext.Response.ContentType = _options.QuotaExceededResponse?.ContentType ?? "text/plain"; + switch (quotaExceededResponse.ResponseType) + { + + case ResponseType.RedirectToAction: + if (string.IsNullOrWhiteSpace(quotaExceededResponse.Action) || + string.IsNullOrWhiteSpace(quotaExceededResponse.Controller)) + { + throw new InvalidOperationException("ResponseType is RedirectToAction, but RedirectAction or RedirectController prop is not set."); + } + + // 传递路由数据 + var routeValues = new QuotaExceededParams() + { + EndPoint = rule.Endpoint, + Path = httpContext.Request.Path.Value, + Limit = rule.Limit, + Period = rule.Period, + RetryAfter = retryAfter + }; + + var redirectUrl = _linkGenerator.GetPathByAction( + httpContext, + action: quotaExceededResponse.Action, + controller: quotaExceededResponse.Controller, + values: new { quotaExceededParams = routeValues }); + httpContext.Response.Redirect(redirectUrl); + return Task.CompletedTask; + + case ResponseType.RedirectToUrl: + if (string.IsNullOrWhiteSpace(quotaExceededResponse.Url)) + { + throw new InvalidOperationException("ResponseType is RedirectToUrl, but RedirectUrl prop is not set."); + } + httpContext.Response.Redirect(quotaExceededResponse.Url); + return Task.CompletedTask; + + default: + var message = string.Format( + quotaExceededResponse.Content ?? "API calls quota exceeded! maximum admitted {0} per {1}.", + rule.Limit, + rule.PeriodTimespan.HasValue ? FormatPeriodTimespan(rule.PeriodTimespan.Value) : rule.Period, retryAfter); + if (!_options.DisableRateLimitHeaders) + { + httpContext.Response.Headers["Retry-After"] = retryAfter; + } + + httpContext.Response.StatusCode = quotaExceededResponse?.StatusCode ?? _options.HttpStatusCode; + httpContext.Response.ContentType = quotaExceededResponse?.ContentType ?? "text/plain"; + + return httpContext.Response.WriteAsync(message); + } - return httpContext.Response.WriteAsync(message); } + + private static string FormatPeriodTimespan(TimeSpan period) { var sb = new StringBuilder(); diff --git a/src/AspNetCoreRateLimit/Models/QuotaExceededParams.cs b/src/AspNetCoreRateLimit/Models/QuotaExceededParams.cs new file mode 100644 index 00000000..de1eefd0 --- /dev/null +++ b/src/AspNetCoreRateLimit/Models/QuotaExceededParams.cs @@ -0,0 +1,33 @@ +namespace AspNetCoreRateLimit.Models +{ + /// + /// The parameters for the Quota Exceeded response + /// + public class QuotaExceededParams + { + /// + /// The endpoint that was hit + /// + public string EndPoint { get; set; } + + /// + /// quota exceeded path + /// + public string Path { get; set; } + + /// + /// The max limit that was set + /// + public double Limit { get; set; } + + /// + /// The period that the limit was set for + /// + public string Period { get; set; } + + /// + /// The retry after time + /// + public string RetryAfter { get; set; } + } +} diff --git a/src/AspNetCoreRateLimit/Models/QuotaExceededResponse.cs b/src/AspNetCoreRateLimit/Models/QuotaExceededResponse.cs index fc5e6097..d2280974 100644 --- a/src/AspNetCoreRateLimit/Models/QuotaExceededResponse.cs +++ b/src/AspNetCoreRateLimit/Models/QuotaExceededResponse.cs @@ -6,6 +6,27 @@ public class QuotaExceededResponse public string Content { get; set; } - public int? StatusCode { get; set; } = 429; + public int? StatusCode { get; set; } + + public string Url { get; set; } + + public string Controller { get; set; } + + public string Action { get; set; } + + public ResponseType ResponseType { get; set; } + + public QuotaExceededResponse() + { + StatusCode = 429; + ResponseType = ResponseType.WriteContent; + } + } + + public enum ResponseType + { + WriteContent, + RedirectToAction, + RedirectToUrl } } diff --git a/src/AspNetCoreRateLimit/Models/RateLimitRule.cs b/src/AspNetCoreRateLimit/Models/RateLimitRule.cs index 2415a07a..e7851d44 100644 --- a/src/AspNetCoreRateLimit/Models/RateLimitRule.cs +++ b/src/AspNetCoreRateLimit/Models/RateLimitRule.cs @@ -14,6 +14,11 @@ public class RateLimitRule /// public string Endpoint { get; set; } + /// + /// A pattern used to identify the uniqueness of an endpoint + /// + public EPConterKeyMode ConterKeyMode { get; set; } = EPConterKeyMode.VerbAndPath; + /// /// Rate limit period as in 1s, 1m, 1h /// @@ -36,4 +41,35 @@ public class RateLimitRule /// public bool MonitorMode { get; set; } = false; } + + /// + /// A pattern used to identify the uniqueness of an endpoint. + /// + public enum EPConterKeyMode + { + /// + /// rate limit is applied to the combination of HTTP verb and path.
+ /// if endpoint is *:/api/values.
+ /// e.g. GET:/api/values/1 and POST:/api/values/1, GET:/api/values/2 and POST:/api/values/2 will be counted separately. + ///
+ VerbAndPath, + /// + /// Path - rate limit is applied to the combination of path.
+ /// if endpoint is *:/api/values.
+ /// GET:/api/values and POST:/api/values will be counted together. + ///
+ Path, + /// + /// rate limit is applied to the combination of HTTP verb and rule.
+ /// if endpoint is *:/api/values.
+ ///GET:/api/values/1 POST:/api/values/2 will be counted separately. but GET:/api/values/1 and GET:/api/values/2 will be counted together. + ///
+ VerbAndRule, + /// + /// rate limit is applied to the combination of rule.
+ /// if endpoint is *:/api/values.
+ /// GET:/api/values/1 , POST:/api/values/1, GET:/api/values/2 and POST:/api/values/2 will be counted together. + ///
+ Rule + } } \ No newline at end of file diff --git a/test/AspNetCoreRateLimit.Demo/Controllers/CounterKeyController.cs b/test/AspNetCoreRateLimit.Demo/Controllers/CounterKeyController.cs new file mode 100644 index 00000000..5629206b --- /dev/null +++ b/test/AspNetCoreRateLimit.Demo/Controllers/CounterKeyController.cs @@ -0,0 +1,53 @@ +using AspNetCoreRateLimit.Models; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace AspNetCoreRateLimit.Demo.Controllers +{ + [Route("api/[controller]")] + public class CounterKeyController : Controller + { + [HttpGet, HttpPost, HttpOptions, HttpDelete, HttpPut, HttpPatch] + [Route("VerbAndPath")] + public string VerbAndPath() + { + return Request.Method; + } + + [HttpGet, HttpPost, HttpOptions, HttpDelete, HttpPut, HttpPatch] + [Route("Path")] + public string Path() + { + return Request.Method; + } + + [HttpGet, HttpPost, HttpOptions, HttpDelete, HttpPut, HttpPatch] + [Route("VerbAndRule/{id:int}")] + public string VerbAndRule(int id) + { + return $"{Request.Method}_{id}"; + } + + [HttpGet, HttpPost, HttpOptions, HttpDelete, HttpPut, HttpPatch] + [Route("Rule/{id:int}")] + public string Rule(int id) + { + return $"{Request.Method}_{id}"; + } + + [HttpGet] + [Route("TestCustomQuotaExceededResponse")] + public string TestCustomQuotaExceededResponse() + { + return Request.Method; + } + + [HttpGet] + [Route("CustomQuotaExceededResponse")] + public string CustomQuotaExceededResponse(QuotaExceededParams quotaExceededParams) + { + Response.StatusCode = 429; + return $"This is customQuotaExceededResponse.\r\n{(quotaExceededParams == null ? string.Empty:JsonSerializer.Serialize(quotaExceededParams))}"; + } + } +} diff --git a/test/AspNetCoreRateLimit.Demo/Controllers/ValuesController.cs b/test/AspNetCoreRateLimit.Demo/Controllers/ValuesController.cs index 7fb07c97..e6bf675a 100644 --- a/test/AspNetCoreRateLimit.Demo/Controllers/ValuesController.cs +++ b/test/AspNetCoreRateLimit.Demo/Controllers/ValuesController.cs @@ -37,5 +37,7 @@ public void Put(int id, [FromBody]string value) public void Delete(int id) { } + + } } diff --git a/test/AspNetCoreRateLimit.Demo/Startup.cs b/test/AspNetCoreRateLimit.Demo/Startup.cs index 8a47735e..51192ac0 100644 --- a/test/AspNetCoreRateLimit.Demo/Startup.cs +++ b/test/AspNetCoreRateLimit.Demo/Startup.cs @@ -48,6 +48,7 @@ public void ConfigureServices(IServiceCollection services) // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + app.UseRouting(); app.UseBlockingDetection(); app.UseIpRateLimiting(); @@ -63,7 +64,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseHsts(); } - app.UseHttpsRedirection(); + //app.UseHttpsRedirection(); app.UseDefaultFiles(new DefaultFilesOptions { @@ -72,6 +73,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseStaticFiles(); app.UseMvc(); + + app.UseEndpoints(endpoints => + { + endpoints.MapDefaultControllerRoute(); + }); } } } \ No newline at end of file diff --git a/test/AspNetCoreRateLimit.Demo/appsettings.json b/test/AspNetCoreRateLimit.Demo/appsettings.json index 8c587347..358ddb01 100644 --- a/test/AspNetCoreRateLimit.Demo/appsettings.json +++ b/test/AspNetCoreRateLimit.Demo/appsettings.json @@ -14,7 +14,7 @@ "RealIpHeader": "X-Real-IP", "HttpStatusCode": 429, "IpWhitelist": [ "::1/10", "192.168.0.0/24" ], - "EndpointWhitelist": [ "delete:/api/values", "*:/api/clients", "*:/api/ClientRateLimit", "*:/api/IpRateLimit", "get:/" ], + "EndpointWhitelist": [ "delete:/api/values", "*:/CounterKey/CustomQuotaExceededResponse", "*:/api/clients", "*:/api/ClientRateLimit", "*:/api/IpRateLimit", "get:/" ], "ClientWhitelist": [ "cl-key-1", "cl-key-2" ], "QuotaExceededResponse": { "Content": "{{ \"message\": \"Whoa! Calm down, cowboy!\", \"details\": \"Quota exceeded. Maximum allowed: {0} per {1}. Please try again in {2} second(s).\" }}", @@ -22,12 +22,12 @@ }, "GeneralRules": [ { - "Endpoint": "*", + "Endpoint": "*:/api/values", "Period": "1s", "Limit": 2 }, { - "Endpoint": "*", + "Endpoint": "*:/api/values", "Period": "1m", "Limit": 5 }, @@ -39,6 +39,41 @@ "Content": "{{ \"data\": [], \"error\": \"Get all user api interface quota exceeded. Maximum allowed: {0} per {1}. Please try again in {2} second(s).\" }}", "ContentType": "application/json" } + }, + { + "Endpoint": "*:/api/CounterKey/VerbAndPath", + "Period": "1m", + "ConterKeyMode": "VerbAndPath", + "Limit": 3 + }, + { + "Endpoint": "*:/api/CounterKey/Path", + "Period": "1m", + "ConterKeyMode": "Path", + "Limit": 3 + }, + { + "Endpoint": "*:/api/CounterKey/VerbAndRule/*", + "Period": "1m", + "ConterKeyMode": "VerbAndRule", + "Limit": 3 + }, + { + "Endpoint": "*:/api/CounterKey/Rule", + "Period": "1m", + "ConterKeyMode": "Rule", + "Limit": 3 + }, + { + "Endpoint": "*:/api/CounterKey/TestCustomQuotaExceededResponse", + "Period": "1m", + "ConterKeyMode": "Path", + "Limit": 3, + "QuotaExceededResponse": { + "ResponseType": "RedirectToAction", + "Controller": "CounterKey", + "Action": "CustomQuotaExceededResponse" + } } ] }, @@ -133,7 +168,7 @@ "EnableEndpointRateLimiting": true, "ClientIdHeader": "X-ClientId", "HttpStatusCode": 429, - "EndpointWhitelist": [ "*:/api/values", "delete:/api/clients", "get:/" ], + "EndpointWhitelist": [ "*:/api/values", "*:/api/counterkey/*", "delete:/api/clients", "get:/" ], "ClientWhitelist": [ "cl-key-a", "cl-key-b" ], "GeneralRules": [ { diff --git a/test/AspNetCoreRateLimit.Tests/IpRateLimitTests.cs b/test/AspNetCoreRateLimit.Tests/IpRateLimitTests.cs index 8400c675..99a0b664 100644 --- a/test/AspNetCoreRateLimit.Tests/IpRateLimitTests.cs +++ b/test/AspNetCoreRateLimit.Tests/IpRateLimitTests.cs @@ -1,4 +1,5 @@ using AspNetCoreRateLimit.Tests.Enums; +using System; using System.Net.Http; using System.Threading.Tasks; using Xunit; @@ -71,6 +72,266 @@ public async Task GlobalIpRule(ClientType clientType) content); } + [Theory] + [InlineData(ClientType.Wildcard)] + public async Task GlobalIpRuleCounterByVerbAndPath(ClientType clientType) + { + const string apiPath = "/api/CounterKey/VerbAndPath"; + // Arrange + var ip = "84.247.86.1"; + + int statusCode = 0; + + var methods = new HttpMethod[] + { + HttpMethod.Get, + HttpMethod.Post, + HttpMethod.Put, + HttpMethod.Patch, + HttpMethod.Options, + HttpMethod.Delete, + }; + + // Act + foreach(var method in methods) + { + using (var request = new HttpRequestMessage(method, apiPath)) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + Assert.NotEqual(429, statusCode); + } + } + } + + ip = "84.247.86.2"; + + for (var i = 0; i < 4; i++) + { + using (var request = new HttpRequestMessage(HttpMethod.Get, apiPath)) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + } + } + } + + Assert.Equal(429, statusCode); + } + + [Theory] + [InlineData(ClientType.Wildcard)] + public async Task GlobalIpRuleCounterByPath(ClientType clientType) + { + const string apiPath = "/api/CounterKey/Path"; + // Arrange + var ip = "84.247.86.3"; + + int statusCode = 0; + + var methods = new HttpMethod[] + { + HttpMethod.Get, + HttpMethod.Post, + HttpMethod.Put, + HttpMethod.Patch, + }; + + // Act + foreach (var method in methods) + { + using (var request = new HttpRequestMessage(method, apiPath)) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + } + } + } + + // Assert + Assert.Equal(429, statusCode); + + ip = "84.247.86.4"; + + for (var i = 0; i < 4; i++) + { + using (var request = new HttpRequestMessage(HttpMethod.Get, apiPath)) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + } + } + } + + Assert.Equal(429, statusCode); + } + + [Theory] + [InlineData(ClientType.Wildcard)] + public async Task GlobalIpRuleCounterByVerbAndRule(ClientType clientType) + { + const string apiPath = "/api/CounterKey/VerbAndRule/{0}"; + // Arrange + var ip = "84.247.86.5"; + + int statusCode = 0; + + string content; + + var methods = new HttpMethod[] + { + HttpMethod.Get, + HttpMethod.Post, + HttpMethod.Put, + HttpMethod.Patch, + }; + + int id = 0; + // Act + foreach (var method in methods) + { + id++; + using (var request = new HttpRequestMessage(method, string.Format(apiPath, id))) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + // Assert + Assert.NotEqual(429, statusCode); + content = await response.Content.ReadAsStringAsync(); + Assert.Equal(content, $"{method.ToString().ToUpper()}_{id}"); + } + } + } + + + ip = "84.247.86.6"; + id = 0; + for (var i = 0; i < 4; i++) + { + id++; + using (var request = new HttpRequestMessage(HttpMethod.Get, string.Format(apiPath, id))) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + if (i < 3) + { + Assert.NotEqual(429, statusCode); + } + else + { + Assert.Equal(429, statusCode); + } + } + } + } + + + } + + [Theory] + [InlineData(ClientType.Wildcard)] + public async Task GlobalIpRuleCounterByRule(ClientType clientType) + { + const string apiPath = "/api/CounterKey/Rule/{0}"; + // Arrange + var ip = "84.247.86.7"; + + int statusCode = 0; + + string content; + + var methods = new HttpMethod[] + { + HttpMethod.Get, + HttpMethod.Post, + HttpMethod.Put, + HttpMethod.Patch, + }; + + int id = 0; + // Act + foreach (var method in methods) + { + using (var request = new HttpRequestMessage(method, string.Format(apiPath, id))) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + content = await response.Content.ReadAsStringAsync(); + if (id < methods.Length) + { + // Assert + Assert.NotEqual(429, statusCode); + Assert.Equal(content, $"{method.ToString().ToUpper()}_{id}"); + } + else + { + Assert.Equal(429, statusCode); + } + } + } + id++; + } + } + + [Theory] + [InlineData(ClientType.Wildcard)] + public async Task GlobalIpRuleRedirectAction(ClientType clientType) + { + const string apiPath = "/api/CounterKey/TestCustomQuotaExceededResponse"; + // Arrange + var ip = "84.247.86.8"; + + int statusCode = 0; + + string content; + // Act + for (int i = 0; i < 4; i++) + { + using (var request = new HttpRequestMessage(HttpMethod.Get, apiPath)) + { + request.Headers.Add("X-Real-IP", ip); + + using (var response = await GetClient(clientType).SendAsync(request)) + { + statusCode = (int)response.StatusCode; + content = await response.Content.ReadAsStringAsync(); + if (i < 3) + { + // Assert + Assert.NotEqual(429, statusCode); + Assert.Equal("GET", content); + } + else + { + Assert.Equal(429, statusCode); + Assert.StartsWith("This is customQuotaExceededResponse.", content); + } + } + } + } + } + [Theory] [InlineData(ClientType.Wildcard)] [InlineData(ClientType.Regex)]