diff --git a/samples/Ocelot.Samples.sln b/samples/Ocelot.Samples.sln index 93aeba9c6..70606a044 100644 --- a/samples/Ocelot.Samples.sln +++ b/samples/Ocelot.Samples.sln @@ -31,6 +31,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ocelot.Samples.ServiceFabri EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ocelot.Samples.Web", "Web\Ocelot.Samples.Web.csproj", "{EA553F5C-4B94-4E4A-8C3E-0124C5EA5F6E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ocelot.Samples.RateLimiter", "RateLimiter\Ocelot.Samples.RateLimiter.csproj", "{C4B2D4B9-D568-42DA-A203-6C33BA2E055D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +95,10 @@ Global {EA553F5C-4B94-4E4A-8C3E-0124C5EA5F6E}.Debug|Any CPU.Build.0 = Debug|Any CPU {EA553F5C-4B94-4E4A-8C3E-0124C5EA5F6E}.Release|Any CPU.ActiveCfg = Release|Any CPU {EA553F5C-4B94-4E4A-8C3E-0124C5EA5F6E}.Release|Any CPU.Build.0 = Release|Any CPU + {C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/RateLimiter/Ocelot.Samples.RateLimiter.csproj b/samples/RateLimiter/Ocelot.Samples.RateLimiter.csproj new file mode 100644 index 000000000..c49a854ca --- /dev/null +++ b/samples/RateLimiter/Ocelot.Samples.RateLimiter.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/samples/RateLimiter/Ocelot.Samples.RateLimiter.http b/samples/RateLimiter/Ocelot.Samples.RateLimiter.http new file mode 100644 index 000000000..f35158ae9 --- /dev/null +++ b/samples/RateLimiter/Ocelot.Samples.RateLimiter.http @@ -0,0 +1,11 @@ +@RateLimiterSample_HostAddress = http://localhost:5202 + +GET {{RateLimiterSample_HostAddress}}/laura +Accept: application/json + +### + +GET {{RateLimiterSample_HostAddress}}/tom +Accept: application/json + +### diff --git a/samples/RateLimiter/Program.cs b/samples/RateLimiter/Program.cs new file mode 100644 index 000000000..99c78bd49 --- /dev/null +++ b/samples/RateLimiter/Program.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.RateLimiting; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using System.Threading.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("ocelot.json"); +builder.Services.AddOcelot(); + +builder.Services.AddRateLimiter(op => +{ + op.AddFixedWindowLimiter(policyName: "fixed", options => + { + options.PermitLimit = 2; + options.Window = TimeSpan.FromSeconds(12); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 0; + }); +}); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +await app.UseOcelot(); + +app.Run(); diff --git a/samples/RateLimiter/Properties/launchSettings.json b/samples/RateLimiter/Properties/launchSettings.json new file mode 100644 index 000000000..9a5cdd083 --- /dev/null +++ b/samples/RateLimiter/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:12083", + "sslPort": 44358 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7116;http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/RateLimiter/appsettings.Development.json b/samples/RateLimiter/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/samples/RateLimiter/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/RateLimiter/appsettings.json b/samples/RateLimiter/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/samples/RateLimiter/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/RateLimiter/ocelot.json b/samples/RateLimiter/ocelot.json new file mode 100644 index 000000000..64b2c4f04 --- /dev/null +++ b/samples/RateLimiter/ocelot.json @@ -0,0 +1,46 @@ +{ + "Routes": [ + { + "UpstreamHttpMethod": [ "Get" ], + "UpstreamPathTemplate": "/laura", + "DownstreamPathTemplate": "/fact", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { "Host": "catfact.ninja", "Port": 443 } + ], + "Key": "Laura", + "RateLimitOptions": { + "EnableRateLimiting": true, + "Period": "5s", + "PeriodTimespan": 1, + "Limit": 1 + } + }, + { + "UpstreamHttpMethod": [ "Get" ], + "UpstreamPathTemplate": "/tom", + "DownstreamPathTemplate": "/fact", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { "Host": "catfact.ninja", "Port": 443 } + ], + "Key": "Tom", + "RateLimitOptions": { + "EnableRateLimiting": true, + "Policy": "fixed" + } + } + ], + "Aggregates": [ + { + "UpstreamPathTemplate": "/", + "RouteKeys": [ "Tom", "Laura" ] + } + ], + "GlobalConfiguration": { + "RateLimitOptions": { + "QuotaExceededMessage": "Customize Tips!", + "HttpStatusCode": 418 // I'm a teapot + } + } +} \ No newline at end of file diff --git a/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs b/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs index 67e39af8b..f9cbda965 100644 --- a/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs +++ b/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs @@ -10,6 +10,7 @@ public class RateLimitOptionsBuilder private string _rateLimitCounterPrefix; private RateLimitRule _rateLimitRule; private int _httpStatusCode; + private string _rateLimitPolicyName; public RateLimitOptionsBuilder WithEnableRateLimiting(bool enableRateLimiting) { @@ -59,11 +60,17 @@ public RateLimitOptionsBuilder WithHttpStatusCode(int httpStatusCode) return this; } + public RateLimitOptionsBuilder WithRateLimitPolicyName(string policyName) + { + _rateLimitPolicyName = policyName; + return this; + } + public RateLimitOptions Build() { return new RateLimitOptions(_enableRateLimiting, _clientIdHeader, _getClientWhitelist, _disableRateLimitHeaders, _quotaExceededMessage, _rateLimitCounterPrefix, - _rateLimitRule, _httpStatusCode); + _rateLimitRule, _httpStatusCode, _rateLimitPolicyName); } } } diff --git a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs index 7bbd7263a..cc78b3909 100644 --- a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs @@ -20,6 +20,7 @@ public RateLimitOptions Create(FileRateLimitRule fileRateLimitRule, FileGlobalCo .WithRateLimitRule(new RateLimitRule(fileRateLimitRule.Period, fileRateLimitRule.PeriodTimespan, fileRateLimitRule.Limit)) + .WithRateLimitPolicyName(fileRateLimitRule.Policy) .Build(); } diff --git a/src/Ocelot/Configuration/File/FileRateLimitRule.cs b/src/Ocelot/Configuration/File/FileRateLimitRule.cs index ffbc0c994..7a79b9995 100644 --- a/src/Ocelot/Configuration/File/FileRateLimitRule.cs +++ b/src/Ocelot/Configuration/File/FileRateLimitRule.cs @@ -14,6 +14,7 @@ public FileRateLimitRule(FileRateLimitRule from) Limit = from.Limit; Period = from.Period; PeriodTimespan = from.PeriodTimespan; + Policy = from.Policy; } /// @@ -56,6 +57,14 @@ public FileRateLimitRule(FileRateLimitRule from) /// public long Limit { get; set; } + /// + /// Rate limit policy name. It only takes effect if rate limit middleware type is set to DotNet. + /// + /// + /// A string of rate limit policy name. + /// + public string Policy { get; set; } + /// public override string ToString() { @@ -65,11 +74,20 @@ public override string ToString() } var sb = new StringBuilder(); - sb.Append( + + if (!string.IsNullOrWhiteSpace(Policy)) + { + sb.Append($"{nameof(Policy)}:{Policy}"); + } + else + { + sb.Append( $"{nameof(Period)}:{Period},{nameof(PeriodTimespan)}:{PeriodTimespan:F},{nameof(Limit)}:{Limit},{nameof(ClientWhitelist)}:["); - sb.AppendJoin(',', ClientWhitelist); - sb.Append(']'); + sb.AppendJoin(',', ClientWhitelist); + sb.Append(']'); + } + return sb.ToString(); } } diff --git a/src/Ocelot/Configuration/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs index 3ff91e44b..243d1574b 100644 --- a/src/Ocelot/Configuration/RateLimitOptions.cs +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -8,7 +8,7 @@ public class RateLimitOptions private readonly Func> _getClientWhitelist; public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func> getClientWhitelist, bool disableRateLimitHeaders, - string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode) + string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode, string rateLimitPolicy = null) { EnableRateLimiting = enableRateLimiting; ClientIdHeader = clientIdHeader; @@ -18,67 +18,68 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func - /// Gets a Rate Limit rule. + Policy = rateLimitPolicy; + } + + /// + /// Gets a Rate Limit rule. /// /// /// A object that represents the rule. /// - public RateLimitRule RateLimitRule { get; } - + public RateLimitRule RateLimitRule { get; } + /// /// Gets the list of white listed clients. /// /// /// A (where T is ) collection with white listed clients. /// - public List ClientWhitelist => _getClientWhitelist(); - + public List ClientWhitelist => _getClientWhitelist(); + /// /// Gets or sets the HTTP header that holds the client identifier, by default is X-ClientId. /// /// /// A string value with the HTTP header. /// - public string ClientIdHeader { get; } - + public string ClientIdHeader { get; } + /// /// Gets or sets the HTTP Status code returned when rate limiting occurs, by default value is set to 429 (Too Many Requests). /// /// - /// An integer value with the HTTP Status code. - /// Default value: 429 (Too Many Requests). + /// An integer value with the HTTP Status code. + /// Default value: 429 (Too Many Requests). /// - public int HttpStatusCode { get; } - + public int HttpStatusCode { get; } + /// - /// Gets or sets a value that will be used as a formatter for the QuotaExceeded response message. + /// Gets or sets a value that will be used as a formatter for the QuotaExceeded response message. /// If none specified the default will be: "API calls quota exceeded! maximum admitted {0} per {1}". /// /// /// A string value with a formatter for the QuotaExceeded response message. /// Default will be: "API calls quota exceeded! maximum admitted {0} per {1}". /// - public string QuotaExceededMessage { get; } - + public string QuotaExceededMessage { get; } + /// /// Gets or sets the counter prefix, used to compose the rate limit counter cache key. /// /// /// A string value with the counter prefix. /// - public string RateLimitCounterPrefix { get; } - + public string RateLimitCounterPrefix { get; } + /// /// Enables endpoint rate limiting based URL path and HTTP verb. /// /// /// A boolean value for enabling endpoint rate limiting based URL path and HTTP verb. /// - public bool EnableRateLimiting { get; } - + public bool EnableRateLimiting { get; } + /// /// Disables X-Rate-Limit and Retry-After headers. /// @@ -86,5 +87,7 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, FuncX-Rate-Limit and Retry-After headers. /// public bool DisableRateLimitHeaders { get; } + + public string Policy { get; } } } diff --git a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs index fbcbd57d2..e241627e0 100644 --- a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs @@ -1,7 +1,7 @@ -using FluentValidation; +using FluentValidation; using Microsoft.AspNetCore.Authentication; -using Ocelot.Configuration.File; -using Ocelot.Configuration.Creator; +using Ocelot.Configuration.File; +using Ocelot.Configuration.Creator; namespace Ocelot.Configuration.Validator { @@ -56,13 +56,16 @@ public RouteFluentValidator(IAuthenticationSchemeProvider authenticationSchemePr When(route => route.RateLimitOptions.EnableRateLimiting, () => { - RuleFor(route => route.RateLimitOptions.Period) + When(IsOcelotRateLimiter, () => + { + RuleFor(route => route.RateLimitOptions.Period) .NotEmpty() .WithMessage("RateLimitOptions.Period is empty"); - RuleFor(route => route.RateLimitOptions) - .Must(IsValidPeriod) - .WithMessage("RateLimitOptions.Period does not contain integer then s (second), m (minute), h (hour), d (day) e.g. 1m for 1 minute period"); + RuleFor(route => route.RateLimitOptions) + .Must(IsValidPeriod) + .WithMessage("RateLimitOptions.Period does not contain integer then s (second), m (minute), h (hour), d (day) e.g. 1m for 1 minute period"); + }); }); RuleFor(route => route.AuthenticationOptions) @@ -85,28 +88,33 @@ public RouteFluentValidator(IAuthenticationSchemeProvider authenticationSchemePr { RuleFor(r => r.DownstreamHttpVersion).Matches("^[0-9]([.,][0-9]{1,1})?$"); }); - - When(route => !string.IsNullOrEmpty(route.DownstreamHttpVersionPolicy), () => - { - RuleFor(r => r.DownstreamHttpVersionPolicy).Matches($@"^({VersionPolicies.RequestVersionExact}|{VersionPolicies.RequestVersionOrHigher}|{VersionPolicies.RequestVersionOrLower})$"); - }); + + When(route => !string.IsNullOrEmpty(route.DownstreamHttpVersionPolicy), () => + { + RuleFor(r => r.DownstreamHttpVersionPolicy).Matches($@"^({VersionPolicies.RequestVersionExact}|{VersionPolicies.RequestVersionOrHigher}|{VersionPolicies.RequestVersionOrLower})$"); + }); } private async Task IsSupportedAuthenticationProviders(FileAuthenticationOptions options, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(options.AuthenticationProviderKey) + if (string.IsNullOrEmpty(options.AuthenticationProviderKey) && options.AuthenticationProviderKeys.Length == 0) { return true; } var schemes = await _authenticationSchemeProvider.GetAllSchemesAsync(); - var supportedSchemes = schemes.Select(scheme => scheme.Name).ToList(); + var supportedSchemes = schemes.Select(scheme => scheme.Name).ToList(); var primary = options.AuthenticationProviderKey; - return !string.IsNullOrEmpty(primary) && supportedSchemes.Contains(primary) + return !string.IsNullOrEmpty(primary) && supportedSchemes.Contains(primary) || (string.IsNullOrEmpty(primary) && options.AuthenticationProviderKeys.All(supportedSchemes.Contains)); } + private static bool IsOcelotRateLimiter(FileRoute fileRoute) + { + return string.IsNullOrWhiteSpace(fileRoute.RateLimitOptions.Policy); + } + private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions) { if (string.IsNullOrEmpty(rateLimitOptions.Period)) @@ -120,7 +128,7 @@ private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions) var minutesRegEx = new Regex("^[0-9]+m"); var hoursRegEx = new Regex("^[0-9]+h"); var daysRegEx = new Regex("^[0-9]+d"); - + return secondsRegEx.Match(period).Success || minutesRegEx.Match(period).Success || hoursRegEx.Match(period).Success diff --git a/src/Ocelot/DependencyInjection/Features.cs b/src/Ocelot/DependencyInjection/Features.cs index 1ea558932..aadfe949c 100644 --- a/src/Ocelot/DependencyInjection/Features.cs +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -1,10 +1,18 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Ocelot.Cache; using Ocelot.Configuration.Creator; using Ocelot.Configuration.File; using Ocelot.DownstreamRouteFinder.HeaderMatcher; using Ocelot.RateLimiting; +#if NET7_0_OR_GREATER +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; +#endif + namespace Ocelot.DependencyInjection; public static class Features @@ -16,10 +24,39 @@ public static class Features /// Read The Docs: Rate Limiting. /// /// The services collection to add the feature to. + /// Root configuration object. /// The same object. public static IServiceCollection AddRateLimiting(this IServiceCollection services) => services .AddSingleton() .AddSingleton(); + +#if NET7_0_OR_GREATER + /// + /// Ocelot feature: AspNet Rate Limiting. + /// + /// + /// Read The Docs: Rate Limiting. + /// + /// The services collection to add the feature to. + /// Root configuration object. + /// The same object. + public static IServiceCollection AddAspNetRateLimiting(this IServiceCollection services, IConfiguration configurationRoot) + { + var globalRateLimitOptions = configurationRoot.Get()?.GlobalConfiguration?.RateLimitOptions; + var rejectStatusCode = globalRateLimitOptions?.HttpStatusCode ?? StatusCodes.Status429TooManyRequests; + var rejectedMessage = globalRateLimitOptions?.QuotaExceededMessage ?? "API calls quota exceeded!"; + services.AddRateLimiter(options => + { + options.OnRejected = async (rejectedContext, token) => + { + rejectedContext.HttpContext.Response.StatusCode = rejectStatusCode; + await rejectedContext.HttpContext.Response.WriteAsync(rejectedMessage, token); + }; + }); + + return services; + } +#endif /// /// Ocelot feature: Request Caching. @@ -50,6 +87,6 @@ public static IServiceCollection AddHeaderRouting(this IServiceCollection servic /// /// The services collection to add the feature to. /// The same object. - public static IServiceCollection AddOcelotMetadata(this IServiceCollection services) => + public static IServiceCollection AddOcelotMetadata(this IServiceCollection services) => services.AddSingleton(); } diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index 642697744..505ff064e 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -104,6 +104,9 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.AddRateLimiting(); // Feature: Rate Limiting +#if NET7_0_OR_GREATER + Services.AddAspNetRateLimiting(configurationRoot); // Feature: AspNet Rate Limiting +#endif Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); diff --git a/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs index 55d784ca7..40d4c2cc1 100644 --- a/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs +++ b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Http; +#if NET7_0_OR_GREATER +using Microsoft.AspNetCore.RateLimiting; +#endif +using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; using Ocelot.Configuration; using Ocelot.Logging; @@ -39,6 +42,18 @@ public async Task Invoke(HttpContext httpContext) return; } + #if NET7_0_OR_GREATER + if (!string.IsNullOrWhiteSpace(options.Policy)) + { + //add EnableRateLimiting attribute to endpoint, so that .Net rate limiter can pick it up and do its thing + var metadata = new EndpointMetadataCollection(new EnableRateLimitingAttribute(options.Policy)); + var endpoint = new Endpoint(null, metadata, "tempEndpoint"); + httpContext.SetEndpoint(endpoint); + await _next.Invoke(httpContext); + return; + } + #endif + // compute identity from request var identity = SetIdentity(httpContext, options); diff --git a/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddlewareExtensions.cs b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddlewareExtensions.cs index 68268cb40..84960b1d7 100644 --- a/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddlewareExtensions.cs +++ b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddlewareExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Ocelot.Middleware; namespace Ocelot.RateLimiting.Middleware; @@ -6,6 +8,22 @@ public static class RateLimitingMiddlewareExtensions { public static IApplicationBuilder UseRateLimiting(this IApplicationBuilder builder) { - return builder.UseMiddleware(); + builder.UseMiddleware(); + + //use AspNet rate limiter +#if NET7_0_OR_GREATER + builder.UseWhen(UseAspNetRateLimiter, rateLimitedApp => + { + rateLimitedApp.UseRateLimiter(); + }); +#endif + + return builder; + } + + private static bool UseAspNetRateLimiter(HttpContext httpContext) + { + var downstreamRoute = httpContext.Items.DownstreamRoute(); + return !string.IsNullOrWhiteSpace(downstreamRoute?.RateLimitOptions?.Policy); } } diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs new file mode 100644 index 000000000..628fae452 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs @@ -0,0 +1,108 @@ +#if NET7_0_OR_GREATER +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.RateLimiting; +#endif +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; + +namespace Ocelot.AcceptanceTests.RateLimiting +{ + public class AspNetRateLimitingTests: RateLimitingSteps + { + private const string _rateLimitPolicyName = "RateLimitPolicy"; + private const int _rateLimitLimit = 3; + private const int _rateLimitWindow = 1; + private const string _quotaExceededMessage = "woah!"; + private readonly ServiceHandler _serviceHandler = new (); + + public override void Dispose() + { + _serviceHandler.Dispose(); + base.Dispose(); + } + +#if NET7_0_OR_GREATER + [Fact] + [Trait("Feat", "2138")] + public void Should_RateLimit() + { + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, _rateLimitPolicyName); + var configuration = GivenConfigurationWithRateLimitOptions(route); + + var ocelotServices = GivenOcelotServices(); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithServices(ocelotServices)) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/", 1)) + .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.OK)) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/", 2)) + .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.OK)) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/", 1)) + .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.TooManyRequests)) + .Then(x => x.ThenTheResponseBodyShouldBe(_quotaExceededMessage)) + .And(x => GivenIWait((1000 * _rateLimitWindow) + 100)) + .When(x => x.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/", 1)) + .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.OK)) + .BDDfy(); + } + + private FileRoute GivenRoute(int port, string rateLimitPolicyName) => new() + { + DownstreamHostAndPorts = new() { new("localhost", port) }, + DownstreamPathTemplate = "/", + DownstreamScheme = Uri.UriSchemeHttp, + UpstreamHttpMethod = new() { HttpMethods.Get }, + UpstreamPathTemplate = "/", + RateLimitOptions = new FileRateLimitRule() + { + EnableRateLimiting = true, + Policy = rateLimitPolicyName, + }, + }; + + private static FileConfiguration GivenConfigurationWithRateLimitOptions(params FileRoute[] routes) + { + var config = GivenConfiguration(routes); + config.GlobalConfiguration = new() + { + RateLimitOptions = new() + { + QuotaExceededMessage = _quotaExceededMessage, + HttpStatusCode = (int)HttpStatusCode.TooManyRequests, + }, + }; + return config; + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, context => + { + context.Response.StatusCode = (int)HttpStatusCode.OK; + context.Response.WriteAsync("test response"); + return Task.CompletedTask; + }); + } + + private Action GivenOcelotServices() => services => + { + services.AddOcelot(); + services.AddRateLimiter(op => + { + op.AddFixedWindowLimiter(_rateLimitPolicyName, options => + { + options.PermitLimit = _rateLimitLimit; + options.Window = TimeSpan.FromSeconds(_rateLimitWindow); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 0; + }); + }); + }; +#endif + } +} diff --git a/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs index e514877d2..3013f708f 100644 --- a/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs @@ -1,7 +1,7 @@ using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Configuration.Creator; -using Ocelot.Configuration.File; +using Ocelot.Configuration.File; namespace Ocelot.UnitTests.Configuration { @@ -19,7 +19,7 @@ public RateLimitOptionsCreatorTests() } [Fact] - public void should_create_rate_limit_options() + public void should_create_rate_limit_options_ocelot() { var fileRoute = new FileRoute { @@ -55,8 +55,52 @@ public void should_create_rate_limit_options() fileRoute.RateLimitOptions.PeriodTimespan, fileRoute.RateLimitOptions.Limit)) .Build(); - - _enabled = false; + + _enabled = false; + + this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) + .And(x => x.GivenTheFollowingFileGlobalConfig(fileGlobalConfig)) + .And(x => x.GivenRateLimitingIsEnabled()) + .When(x => x.WhenICreate()) + .Then(x => x.ThenTheFollowingIsReturned(expected)) + .BDDfy(); + } + + [Fact] + [Trait("Feat", "2138")] + public void should_create_rate_limit_options_aspnet() + { + var fileRoute = new FileRoute + { + RateLimitOptions = new FileRateLimitRule + { + Policy = "test", + EnableRateLimiting = true, + }, + }; + var fileGlobalConfig = new FileGlobalConfiguration + { + RateLimitOptions = new FileRateLimitOptions + { + ClientIdHeader = "ClientIdHeader", + DisableRateLimitHeaders = true, + QuotaExceededMessage = "QuotaExceededMessage", + HttpStatusCode = 200, + }, + }; + var expected = new RateLimitOptionsBuilder() + .WithClientIdHeader("ClientIdHeader") + .WithClientWhiteList(() => new List()) + .WithDisableRateLimitHeaders(true) + .WithEnableRateLimiting(true) + .WithHttpStatusCode(200) + .WithQuotaExceededMessage("QuotaExceededMessage") + .WithRateLimitCounterPrefix("ocelot") + .WithRateLimitRule(new RateLimitRule(null, 0, 0)) + .WithRateLimitPolicyName("test") + .Build(); + + _enabled = false; this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .And(x => x.GivenTheFollowingFileGlobalConfig(fileGlobalConfig)) @@ -87,7 +131,7 @@ private void WhenICreate() } private void ThenTheFollowingIsReturned(RateLimitOptions expected) - { + { _enabled.ShouldBeTrue(); _result.ClientIdHeader.ShouldBe(expected.ClientIdHeader); _result.ClientWhitelist.ShouldBe(expected.ClientWhitelist); diff --git a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs index 1b2bfadb3..257c054dd 100644 --- a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs @@ -1,9 +1,10 @@ -using FluentValidation.Results; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration.File; -using Ocelot.Configuration.Validator; -using System.Reflection; +using FluentValidation.Results; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using Ocelot.Configuration.File; +using Ocelot.Configuration.Validator; +using System.Reflection; namespace Ocelot.UnitTests.Configuration.Validation { @@ -191,31 +192,31 @@ public void should_not_be_valid_if_enable_rate_limiting_true_and_period_has_valu .Then(_ => ThenTheResultIsInvalid()) .And(_ => ThenTheErrorsContains("RateLimitOptions.Period does not contain integer then s (second), m (minute), h (hour), d (day) e.g. 1m for 1 minute period")) .BDDfy(); - } - - [Theory] - [InlineData(null, false)] - [InlineData("", false)] - [InlineData("1s", true)] - [InlineData("2m", true)] - [InlineData("3h", true)] - [InlineData("4d", true)] - [InlineData("123", false)] - [InlineData("-123", false)] - [InlineData("bad", false)] - [InlineData(" 3s ", true)] - [InlineData(" -3s ", false)] - public void IsValidPeriod_ReflectionLifeHack_BranchesAreCovered(string period, bool expected) - { - // Arrange - var method = _validator.GetType().GetMethod("IsValidPeriod", BindingFlags.NonPublic | BindingFlags.Static); - var argument = new FileRateLimitRule { Period = period }; - - // Act - bool actual = (bool)method.Invoke(_validator, new object[] { argument }); - - // Assert - Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("1s", true)] + [InlineData("2m", true)] + [InlineData("3h", true)] + [InlineData("4d", true)] + [InlineData("123", false)] + [InlineData("-123", false)] + [InlineData("bad", false)] + [InlineData(" 3s ", true)] + [InlineData(" -3s ", false)] + public void IsValidPeriod_ReflectionLifeHack_BranchesAreCovered(string period, bool expected) + { + // Arrange + var method = _validator.GetType().GetMethod("IsValidPeriod", BindingFlags.NonPublic | BindingFlags.Static); + var argument = new FileRateLimitRule { Period = period }; + + // Act + bool actual = (bool)method.Invoke(_validator, new object[] { argument }); + + // Assert + Assert.Equal(expected, actual); } [Fact] diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs index 741af9d54..4c2deb5fd 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs @@ -1,9 +1,9 @@ -using Ocelot.Configuration; -using Ocelot.Configuration.Builder; -using Ocelot.Configuration.Creator; -using Ocelot.DownstreamRouteFinder.Finder; -using Ocelot.LoadBalancer.LoadBalancers; -using Ocelot.Responses; +using Ocelot.Configuration; +using Ocelot.Configuration.Builder; +using Ocelot.Configuration.Creator; +using Ocelot.DownstreamRouteFinder.Finder; +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Responses; namespace Ocelot.UnitTests.DownstreamRouteFinder { @@ -16,7 +16,7 @@ public class DownstreamRouteCreatorTests : UnitTest private Response _result; private string _upstreamHost; private string _upstreamUrlPath; - private string _upstreamHttpMethod; + private string _upstreamHttpMethod; private Dictionary _upstreamHeaders; private IInternalConfiguration _configuration; private readonly Mock _qosOptionsCreator; @@ -32,7 +32,7 @@ public DownstreamRouteCreatorTests() _qosOptionsCreator .Setup(x => x.Create(It.IsAny(), It.IsAny(), It.IsAny>())) .Returns(_qoSOptions); - _creator = new DownstreamRouteCreator(_qosOptionsCreator.Object); + _creator = new DownstreamRouteCreator(_qosOptionsCreator.Object); _upstreamQuery = string.Empty; } @@ -58,11 +58,13 @@ public void should_create_downstream_route() } [Fact] + [Trait("Feat", "2138")] public void should_create_downstream_route_with_rate_limit_options() { var rateLimitOptions = new RateLimitOptionsBuilder() .WithEnableRateLimiting(true) .WithClientIdHeader("test") + .WithRateLimitPolicyName("test") .Build(); var downstreamRoute = new DownstreamRouteBuilder() @@ -370,7 +372,7 @@ private void GivenTheConfiguration(IInternalConfiguration config) { _upstreamHost = "doesnt matter"; _upstreamUrlPath = "/auth/test"; - _upstreamHttpMethod = "GET"; + _upstreamHttpMethod = "GET"; _upstreamHeaders = new Dictionary(); _configuration = config; } diff --git a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs index 809961a1d..759c307b0 100644 --- a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Http; +#if NET7_0_OR_GREATER +using Microsoft.AspNetCore.RateLimiting; +#endif +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; using Ocelot.Configuration; using Ocelot.Configuration.Builder; @@ -58,7 +61,8 @@ public async Task Should_call_middleware_and_ratelimiting() quotaExceededMessage: "Exceeding!", rateLimitCounterPrefix: string.Empty, new RateLimitRule("1s", 100.0D, limit), - (int)HttpStatusCode.TooManyRequests)) + (int)HttpStatusCode.TooManyRequests, + string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .WithUpstreamPathTemplate(upstreamTemplate) .Build(); @@ -100,7 +104,8 @@ public async Task Should_call_middleware_withWhitelistClient() quotaExceededMessage: "Exceeding!", rateLimitCounterPrefix: string.Empty, new RateLimitRule("1s", 100.0D, 3), - (int)HttpStatusCode.TooManyRequests)) + (int)HttpStatusCode.TooManyRequests, + string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .Build()) .WithUpstreamHttpMethod(new() { "Get" }) @@ -132,7 +137,8 @@ public async Task MiddlewareInvoke_PeriodTimespanValueIsGreaterThanPeriod_Status quotaExceededMessage: "Exceeding!", rateLimitCounterPrefix: string.Empty, new RateLimitRule("1s", 30.0D, limit), // bug scenario - (int)HttpStatusCode.TooManyRequests)) + (int)HttpStatusCode.TooManyRequests, + string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .WithUpstreamPathTemplate(upstreamTemplate) .Build(); @@ -143,7 +149,9 @@ public async Task MiddlewareInvoke_PeriodTimespanValueIsGreaterThanPeriod_Status var downstreamRouteHolder = new _DownstreamRouteHolder_(new(), route); // Act, Assert: 100 requests must be successful - var contexts = await WhenICallTheMiddlewareMultipleTimes(limit, downstreamRouteHolder); // make 100 requests, but not exceed the limit + var contexts = + await WhenICallTheMiddlewareMultipleTimes(limit, + downstreamRouteHolder); // make 100 requests, but not exceed the limit _downstreamResponses.ForEach(dsr => dsr.ShouldBeNull()); contexts.ForEach(ctx => { @@ -163,7 +171,54 @@ public async Task MiddlewareInvoke_PeriodTimespanValueIsGreaterThanPeriod_Status contexts[0].Items.Errors().Single().HttpStatusCode.ShouldBe((int)HttpStatusCode.TooManyRequests); } - private async Task> WhenICallTheMiddlewareMultipleTimes(long times, _DownstreamRouteHolder_ downstreamRoute) + #if NET7_0_OR_GREATER + [Fact] + [Trait("Feat", "2138")] + public async Task Should_add_EnableRateLimittingAttribute_When_AspNetRateLimiting() + { + // Arrange + const long limit = 3L; + var upstreamTemplate = new UpstreamPathTemplateBuilder() + .Build(); + var downstreamRoute = new DownstreamRouteBuilder() + .WithEnableRateLimiting(true) + .WithRateLimitOptions(new( + enableRateLimiting: true, + clientIdHeader: null, + getClientWhitelist: null, + disableRateLimitHeaders: false, + quotaExceededMessage: null, + rateLimitCounterPrefix: null, + null, + (int)HttpStatusCode.TooManyRequests, + "testPolicy")) + .WithUpstreamHttpMethod(new() { "Get" }) + .WithUpstreamPathTemplate(upstreamTemplate) + .Build(); + var route = new RouteBuilder() + .WithDownstreamRoute(downstreamRoute) + .WithUpstreamHttpMethod(new() { "Get" }) + .Build(); + var downstreamRouteHolder = new _DownstreamRouteHolder_(new(), route); + + // Act, Assert + var contexts = await WhenICallTheMiddlewareMultipleTimes(limit+1, downstreamRouteHolder); + _downstreamResponses.ForEach(dsr => dsr.ShouldBeNull()); + + contexts.ForEach(ctx => + { + var endpoint = ctx.GetEndpoint(); + endpoint.ShouldNotBeNull(); + + var rateLimitAttribute = endpoint.Metadata.GetMetadata(); + rateLimitAttribute.PolicyName.ShouldBe("testPolicy"); + }); + + } +#endif + + private async Task> WhenICallTheMiddlewareMultipleTimes(long times, + _DownstreamRouteHolder_ downstreamRoute) { var contexts = new List(); _downstreamResponses.Clear();