diff --git a/samples/Ocelot.Samples.sln b/samples/Ocelot.Samples.sln index f855def53..1cc35ceef 100644 --- a/samples/Ocelot.Samples.sln +++ b/samples/Ocelot.Samples.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ocelot.Samples.Configuratio EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ocelot.Samples.Metadata", "Metadata\Ocelot.Samples.Metadata.csproj", "{80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}" 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 @@ -99,6 +101,10 @@ Global {80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}.Debug|Any CPU.Build.0 = Debug|Any CPU {80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}.Release|Any CPU.ActiveCfg = Release|Any CPU {80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}.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/Creator/RateLimitOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs index 3c1ffd749..668368cfa 100644 --- a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs @@ -81,6 +81,7 @@ protected virtual RateLimitOptions MergeHeaderRules(FileRateLimitByHeaderRule ru rule.Wait = rule.Wait.IfEmpty(globalRule.Wait.IfEmpty(RateLimitRule.ZeroWait)); rule.Limit ??= globalRule.Limit ?? RateLimitRule.ZeroLimit; + rule.Policy = rule.Policy.IfEmpty(globalRule.Policy); return new(rule); } } diff --git a/src/Ocelot/Configuration/File/FileRateLimitRule.cs b/src/Ocelot/Configuration/File/FileRateLimitRule.cs index d98ad0fbe..531c0d255 100644 --- a/src/Ocelot/Configuration/File/FileRateLimitRule.cs +++ b/src/Ocelot/Configuration/File/FileRateLimitRule.cs @@ -18,6 +18,7 @@ public FileRateLimitRule(FileRateLimitRule from) Limit = from.Limit; Period = from.Period; PeriodTimespan = from.PeriodTimespan; + Policy = from.Policy; Wait = from.Wait; StatusCode = from.StatusCode; QuotaMessage = from.QuotaMessage; @@ -74,6 +75,14 @@ public FileRateLimitRule(FileRateLimitRule from) /// A object which value defaults to "Ocelot.RateLimiting", see the property. public string KeyPrefix { 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; } + /// /// Returns a string that represents the current rule in the format, which defaults to empty string if rate limiting is disabled ( is ). /// @@ -85,6 +94,10 @@ public override string ToString() { return string.Empty; } + else if (!string.IsNullOrWhiteSpace(Policy)) + { + return $"{nameof(Policy)}:{Policy}"; + } char hdrSign = EnableHeaders == false ? '-' : '+'; string waitWindow = PeriodTimespan.HasValue ? PeriodTimespan.Value.ToString("F3") + 's' : Wait.IfEmpty(None); diff --git a/src/Ocelot/Configuration/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs index 9785ba7a0..1b5860dbc 100644 --- a/src/Ocelot/Configuration/RateLimitOptions.cs +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -35,7 +35,8 @@ public RateLimitOptions(bool enableRateLimiting) : this() } public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, IList clientWhitelist, bool enableHeaders, - string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode) + string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode, + string policy = null) { ClientIdHeader = clientIdHeader.IfEmpty(DefaultClientHeader); ClientWhitelist = clientWhitelist ?? []; @@ -45,8 +46,9 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, IListGets a Rate Limit rule. @@ -121,4 +125,6 @@ public RateLimitOptions(RateLimitOptions fromOptions) /// Enables or disables X-RateLimit-* and Retry-After headers. /// A value. public bool EnableHeaders { get; init; } + + public string Policy { get; init; } } diff --git a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs index a6ae145c6..bbf84350d 100644 --- a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs @@ -56,7 +56,9 @@ public RouteFluentValidator( .WithMessage("{PropertyName} {PropertyValue} contains scheme"); }); - When(route => route.RateLimitOptions != null && route.RateLimitOptions.EnableRateLimiting != false, () => + When(route => route.RateLimitOptions != null + && route.RateLimitOptions.EnableRateLimiting != false + && string.IsNullOrWhiteSpace(route.RateLimitOptions.Policy), () => { RuleFor(route => route.RateLimitOptions.Limit) .Must(limit => !limit.HasValue || (limit.HasValue && limit.Value > 0)) diff --git a/src/Ocelot/DependencyInjection/Features.cs b/src/Ocelot/DependencyInjection/Features.cs index 3e2457e31..5d1689512 100644 --- a/src/Ocelot/DependencyInjection/Features.cs +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -1,4 +1,7 @@ using FluentValidation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Ocelot.Cache; using Ocelot.Configuration.Creator; @@ -35,6 +38,32 @@ public static IServiceCollection AddConfigurationValidators(this IServiceCollect public static IServiceCollection AddOcelotRateLimiting(this IServiceCollection services) => services .AddSingleton() .AddSingleton(); + + /// + /// 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; + } /// /// Ocelot feature: Request Caching. diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index ff343fafe..86aa16b06 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -137,6 +137,7 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.AddOcelotMessageInvokerPool(); Services.AddOcelotMetadata(); Services.AddOcelotRateLimiting(); + Services.AddAspNetRateLimiting(configurationRoot); // Feature: AspNet Rate Limiting // Add ASP.NET services var assembly = typeof(FileConfigurationController).GetTypeInfo().Assembly; diff --git a/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs b/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs index 296d60ceb..a032358ae 100644 --- a/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs +++ b/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Ocelot.Configuration; @@ -39,6 +40,15 @@ public Task Invoke(HttpContext context) return _next.Invoke(context); } + if (!options.Policy.IsEmpty()) + { + // 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"); + context.SetEndpoint(endpoint); + return _next.Invoke(context); + } + var identity = Identify(context, options, downstreamRoute); if (IsWhitelisted(identity, options)) { diff --git a/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs b/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs new file mode 100644 index 000000000..6e9f1219a --- /dev/null +++ b/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Ocelot.Middleware; + +namespace Ocelot.RateLimiting.Middleware; + +public static class RateLimitingMiddlewareExtensions +{ + public static IApplicationBuilder UseRateLimiting(this IApplicationBuilder builder) + { + builder.UseMiddleware(); + + //use AspNet rate limiter + builder.UseWhen(UseAspNetRateLimiter, rateLimitedApp => + { + rateLimitedApp.UseRateLimiter(); + }); + 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..c0b0a00bb --- /dev/null +++ b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; +using System.Threading.RateLimiting; + +namespace Ocelot.AcceptanceTests.RateLimiting; + +public class AspNetRateLimitingTests: RateLimitingSteps +{ + private const string FixedWindowPolicyName = "RateLimitPolicy"; + private int _rateLimitLimit; + private int _rateLimitWindow; + private string _quotaExceededMessage; + + [Fact] + [Trait("Feat", "2138")] + public async Task Should_RateLimit() + { + _rateLimitLimit = 3; + _rateLimitWindow = 1; + _quotaExceededMessage = "woah!"; + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, FixedWindowPolicyName); + var configuration = GivenConfiguration(route); + GivenThereIsAServiceRunningOnPath(port, "/"); + GivenThereIsAConfiguration(configuration); + GivenOcelotIsRunning(WithRateLimiter); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); + ThenTheStatusCodeShouldBeOK(); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", _rateLimitLimit - 1); + ThenTheStatusCodeShouldBeOK(); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); + ThenTheStatusCodeShouldBe(HttpStatusCode.TooManyRequests); + ThenTheResponseBodyShouldBe(_quotaExceededMessage); + GivenIWait((1000 * _rateLimitWindow) + 100); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); + ThenTheStatusCodeShouldBeOK(); + } + + private FileRoute GivenRoute(int port, string rateLimitPolicyName) + { + var route = GivenRoute(port); + route.RateLimitOptions = new() + { + Policy = rateLimitPolicyName, + }; + return route; + } + + private FileConfiguration GivenConfiguration(params FileRoute[] routes) + { + var config = base.GivenConfiguration(routes); + config.GlobalConfiguration.RateLimitOptions = new() + { + QuotaExceededMessage = _quotaExceededMessage, + HttpStatusCode = StatusCodes.Status429TooManyRequests, + }; + return config; + } + + private void WithRateLimiter(IServiceCollection services) => services + .AddOcelot().Services + .AddRateLimiter(op => + { + op.AddFixedWindowLimiter(FixedWindowPolicyName, options => + { + options.PermitLimit = _rateLimitLimit; + options.Window = TimeSpan.FromSeconds(_rateLimitWindow); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 0; + }); + }); +} diff --git a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs index eaa5de7fe..d01bc104a 100644 --- a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs @@ -1,6 +1,7 @@ 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; diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs index c11dca83a..090cf1c06 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs @@ -57,6 +57,7 @@ public void Should_create_downstream_route() [Fact] [Trait("Feat", "585")] [Trait("Feat", "1229")] + [Trait("Feat", "2138")] public void Should_create_downstream_route_with_rate_limit_options() { // Arrange @@ -64,6 +65,7 @@ public void Should_create_downstream_route_with_rate_limit_options() { EnableRateLimiting = true, ClientIdHeader = "test", + Policy = "test", }; var downstreamRoute = new DownstreamRouteBuilder() .WithServiceName("auth") diff --git a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs index 4eba538ce..6d0924ea9 100644 --- a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Caching.Memory; using Ocelot.Configuration; using Ocelot.Configuration.Builder; @@ -257,6 +258,36 @@ public async Task Invoke_NoClientHeader_Status503_ShouldLogWarning() _logger.Verify(x => x.LogWarning(err.Message), Times.Once); } + [Fact] + [Trait("Feat", "2138")] + public async Task Should_add_EnableRateLimittingAttribute_When_AspNetRateLimiting() + { + // Arrange + const long limit = 3L; + RateLimitOptions options = new() + { + ClientIdHeader = "ClientId", + Rule = new("1s", "1s", limit), + Policy = "testPolicy", + }; + var downstreamRoute = GivenDownstreamRoute(options); + var route = GivenRoute(downstreamRoute); + var dsHolder = new _DownstreamRouteHolder_(new(), route); + + // Act + var contexts = await WhenICallTheMiddlewareMultipleTimes(limit+1, dsHolder); + + // Assert + _downstreamResponses.ForEach(dsr => dsr.ShouldBeNull()); + contexts.ForEach(ctx => + { + var endpoint = ctx.GetEndpoint(); + endpoint.ShouldNotBeNull(); + var rateLimitAttribute = endpoint.Metadata.GetMetadata(); + rateLimitAttribute.PolicyName.ShouldBe("testPolicy"); + }); + } + private static RateLimitOptions GivenRateLimitOptions(RateLimitRule rule = null, [CallerMemberName] string testName = null) => new( enableRateLimiting: true, clientIdHeader: "ClientId",