From c7c4c6bedea724166fd51f18aaeb50401e10eb3a Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Mon, 4 Nov 2024 00:09:12 -0600 Subject: [PATCH 01/14] draft implementation of .net rate limiter in ocelot --- .../Builder/RateLimitOptionsBuilder.cs | 16 +++++- .../Creator/RateLimitOptionsCreator.cs | 2 + .../Configuration/File/FileRateLimitRule.cs | 35 +++++++++++-- .../Configuration/RateLimitMiddlewareType.cs | 7 +++ src/Ocelot/Configuration/RateLimitOptions.cs | 51 +++++++++++-------- .../Validator/RouteFluentValidator.cs | 51 +++++++++++++------ src/Ocelot/DependencyInjection/Features.cs | 32 ++++++++++-- .../Middleware/RateLimitingMiddleware.cs | 17 ++++++- 8 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 src/Ocelot/Configuration/RateLimitMiddlewareType.cs diff --git a/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs b/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs index 67e39af8b..7c0b075b3 100644 --- a/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs +++ b/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs @@ -10,6 +10,8 @@ public class RateLimitOptionsBuilder private string _rateLimitCounterPrefix; private RateLimitRule _rateLimitRule; private int _httpStatusCode; + private RateLimitMiddlewareType _rateLimitMiddlewareType; + private string _rateLimitPolicyName; public RateLimitOptionsBuilder WithEnableRateLimiting(bool enableRateLimiting) { @@ -59,11 +61,23 @@ public RateLimitOptionsBuilder WithHttpStatusCode(int httpStatusCode) return this; } + public RateLimitOptionsBuilder WithRateLimitMiddlewareType(RateLimitMiddlewareType middlewareType) + { + _rateLimitMiddlewareType = middlewareType; + 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, _rateLimitMiddlewareType, _rateLimitPolicyName); } } } diff --git a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs index 7bbd7263a..18a1cf39f 100644 --- a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs @@ -20,6 +20,8 @@ public RateLimitOptions Create(FileRateLimitRule fileRateLimitRule, FileGlobalCo .WithRateLimitRule(new RateLimitRule(fileRateLimitRule.Period, fileRateLimitRule.PeriodTimespan, fileRateLimitRule.Limit)) + .WithRateLimitMiddlewareType(fileRateLimitRule.RateLimitMiddlewareType) + .WithRateLimitPolicyName(fileRateLimitRule.RateLimitPolicyName) .Build(); } diff --git a/src/Ocelot/Configuration/File/FileRateLimitRule.cs b/src/Ocelot/Configuration/File/FileRateLimitRule.cs index ffbc0c994..adb2fcd71 100644 --- a/src/Ocelot/Configuration/File/FileRateLimitRule.cs +++ b/src/Ocelot/Configuration/File/FileRateLimitRule.cs @@ -14,6 +14,8 @@ public FileRateLimitRule(FileRateLimitRule from) Limit = from.Limit; Period = from.Period; PeriodTimespan = from.PeriodTimespan; + RateLimitMiddlewareType = from.RateLimitMiddlewareType; + RateLimitPolicyName = from.RateLimitPolicyName; } /// @@ -56,6 +58,22 @@ public FileRateLimitRule(FileRateLimitRule from) /// public long Limit { get; set; } + /// + /// Rate limit middleware type. + /// + /// + /// , + /// + public RateLimitMiddlewareType RateLimitMiddlewareType { 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 RateLimitPolicyName { get; set; } + /// public override string ToString() { @@ -65,11 +83,22 @@ public override string ToString() } var sb = new StringBuilder(); - sb.Append( + + sb.Append($"{nameof(RateLimitMiddlewareType)}:{RateLimitMiddlewareType},"); + + if (RateLimitMiddlewareType == RateLimitMiddlewareType.DotNet) + { + sb.Append($"{nameof(RateLimitPolicyName)}:{RateLimitPolicyName}"); + } + else if (RateLimitMiddlewareType == RateLimitMiddlewareType.Ocelot) + { + 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/RateLimitMiddlewareType.cs b/src/Ocelot/Configuration/RateLimitMiddlewareType.cs new file mode 100644 index 000000000..3a4e14ace --- /dev/null +++ b/src/Ocelot/Configuration/RateLimitMiddlewareType.cs @@ -0,0 +1,7 @@ +namespace Ocelot.Configuration; + +public enum RateLimitMiddlewareType +{ + Ocelot, + DotNet, +} diff --git a/src/Ocelot/Configuration/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs index c768bb55c..8e8539141 100644 --- a/src/Ocelot/Configuration/RateLimitOptions.cs +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -8,7 +8,8 @@ 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, + RateLimitMiddlewareType rateLimitMiddlewareType, string rateLimitPolicyName) { EnableRateLimiting = enableRateLimiting; ClientIdHeader = clientIdHeader; @@ -18,67 +19,69 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func - /// Gets a Rate Limit rule. + RateLimitMiddlewareType = rateLimitMiddlewareType; + RateLimiterPolicyName = rateLimitPolicyName; + } + + /// + /// 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 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 Rety-After headers. /// @@ -86,5 +89,9 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func public bool DisableRateLimitHeaders { get; } + + public RateLimitMiddlewareType RateLimitMiddlewareType { get; } + + public string RateLimiterPolicyName { get; } } } diff --git a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs index fbcbd57d2..43d315d8f 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,22 @@ 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"); + }); + + When(IsDotNetRateLimiter, () => { + RuleFor(route => route.RateLimitOptions.RateLimitPolicyName) + .NotEmpty() + .WithMessage("{PropertyValue} is empty."); + }); }); RuleFor(route => route.AuthenticationOptions) @@ -85,28 +94,38 @@ 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 fileRoute.RateLimitOptions.RateLimitMiddlewareType == RateLimitMiddlewareType.Ocelot; + } + + private static bool IsDotNetRateLimiter(FileRoute fileRoute) + { + return fileRoute.RateLimitOptions.RateLimitMiddlewareType == RateLimitMiddlewareType.DotNet; + } + private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions) { if (string.IsNullOrEmpty(rateLimitOptions.Period)) @@ -120,7 +139,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..d2b64bfa9 100644 --- a/src/Ocelot/DependencyInjection/Features.cs +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -1,10 +1,17 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +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 @@ -17,9 +24,24 @@ public static class Features /// /// The services collection to add the feature to. /// The same object. - public static IServiceCollection AddRateLimiting(this IServiceCollection services) => services - .AddSingleton() - .AddSingleton(); + public static IServiceCollection AddRateLimiting(this IServiceCollection services) + { + services + .AddSingleton() + .AddSingleton(); + +#if NET7_0_OR_GREATER + services.AddRateLimiter(options => + { + options.OnRejected = async (rejectedContext, token) => + { + rejectedContext.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await rejectedContext.HttpContext.Response.WriteAsync("Too many requests. Please try again later.", token); + }; + }); +#endif + return services; + } /// /// Ocelot feature: Request Caching. @@ -50,6 +72,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/RateLimiting/Middleware/RateLimitingMiddleware.cs b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs index b407733ae..b42b0458d 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 Ocelot.Configuration; using Ocelot.Logging; using Ocelot.Middleware; @@ -35,6 +38,18 @@ public async Task Invoke(HttpContext httpContext) return; } + #if NET7_0_OR_GREATER + if (options.RateLimitMiddlewareType == RateLimitMiddlewareType.DotNet) + { + //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.RateLimiterPolicyName)); + 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); From b6c15772f7732cdb5512b8d5d4acd3c355ae2022 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Tue, 5 Nov 2024 22:44:20 -0600 Subject: [PATCH 02/14] fixed tests --- .../Validator/RouteFluentValidator.cs | 2 +- .../DownstreamRouteExtensionsTests.cs | 2 +- .../RateLimitOptionsCreatorTests.cs | 56 ++++++++- .../Validation/RouteFluentValidatorTests.cs | 110 ++++++++++++------ .../DownstreamRouteCreatorTests.cs | 20 ++-- .../RateLimitingMiddlewareTests.cs | 71 ++++++++++- 6 files changed, 206 insertions(+), 55 deletions(-) diff --git a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs index 43d315d8f..f880b1f65 100644 --- a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs @@ -70,7 +70,7 @@ public RouteFluentValidator(IAuthenticationSchemeProvider authenticationSchemePr When(IsDotNetRateLimiter, () => { RuleFor(route => route.RateLimitOptions.RateLimitPolicyName) .NotEmpty() - .WithMessage("{PropertyValue} is empty."); + .WithMessage("RateLimitOptions.RateLimitPolicyName is required when RateLimitOptions.RateLimitMiddlewareType is DotNet."); }); }); diff --git a/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs b/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs index 0c3b3bcc3..b287c218e 100644 --- a/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs +++ b/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs @@ -31,7 +31,7 @@ public DownstreamRouteExtensionsTests() default, new CacheOptions(0, null, null, null), new LoadBalancerOptions(null, null, 0), - new RateLimitOptions(false, null, null, false, null, null, null, 0), + new RateLimitOptions(false, null, null, false, null, null, null, 0, RateLimitMiddlewareType.Ocelot, null), new Dictionary(), new List(), new List(), diff --git a/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs index e514877d2..d455a66f0 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 { @@ -54,9 +54,55 @@ public void should_create_rate_limit_options() .WithRateLimitRule(new RateLimitRule(fileRoute.RateLimitOptions.Period, fileRoute.RateLimitOptions.PeriodTimespan, fileRoute.RateLimitOptions.Limit)) + .WithRateLimitMiddlewareType(RateLimitMiddlewareType.Ocelot) .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] + public void should_create_rate_limit_options_dotnet() + { + var fileRoute = new FileRoute + { + RateLimitOptions = new FileRateLimitRule + { + RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, + RateLimitPolicyName = "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)) + .WithRateLimitMiddlewareType(RateLimitMiddlewareType.DotNet) + .WithRateLimitPolicyName("test") + .Build(); + + _enabled = false; this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .And(x => x.GivenTheFollowingFileGlobalConfig(fileGlobalConfig)) @@ -87,7 +133,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..b05a4b0e4 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 { @@ -153,7 +154,7 @@ public void upstream_path_template_should_not_contain_scheme(string upstreamPath } [Fact] - public void should_not_be_valid_if_enable_rate_limiting_true_and_period_is_empty() + public void should_not_be_valid_if_enable_rate_limiting_true_type_ocelot_and_period_is_empty() { var fileRoute = new FileRoute { @@ -173,7 +174,7 @@ public void should_not_be_valid_if_enable_rate_limiting_true_and_period_is_empty } [Fact] - public void should_not_be_valid_if_enable_rate_limiting_true_and_period_has_value() + public void should_not_be_valid_if_enable_rate_limiting_true_type_ocelot_and_period_has_value() { var fileRoute = new FileRoute { @@ -191,31 +192,74 @@ 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); + } + + [Fact] + public void should_not_be_valid_if_enable_rate_limiting_true_type_dotnet_and_policy_is_empty() + { + var fileRoute = new FileRoute + { + DownstreamPathTemplate = "/test", + UpstreamPathTemplate = "/test", + RateLimitOptions = new FileRateLimitRule + { + EnableRateLimiting = true, + RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, + }, + }; + + this.Given(_ => GivenThe(fileRoute)) + .When(_ => WhenIValidate()) + .Then(_ => ThenTheResultIsInvalid()) + .And(_ => ThenTheErrorsContains("RateLimitOptions.RateLimitPolicyName is required when RateLimitOptions.RateLimitMiddlewareType is DotNet.")) + .BDDfy(); + } + + [Fact] + public void should_be_valid_if_enable_rate_limiting_true_type_dotnet_and_policy_has_value() + { + var fileRoute = new FileRoute + { + DownstreamPathTemplate = "/test", + UpstreamPathTemplate = "/test", + ServiceName = "Lads", + RateLimitOptions = new FileRateLimitRule + { + EnableRateLimiting = true, + RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, + RateLimitPolicyName = "testPolicy", + }, + }; + + this.Given(_ => GivenThe(fileRoute)) + .When(_ => WhenIValidate()) + .Then(_ => ThenTheResultIsValid()) + .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); } [Fact] diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs index 741af9d54..d39186791 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; } @@ -63,6 +63,8 @@ public void should_create_downstream_route_with_rate_limit_options() var rateLimitOptions = new RateLimitOptionsBuilder() .WithEnableRateLimiting(true) .WithClientIdHeader("test") + .WithRateLimitMiddlewareType(RateLimitMiddlewareType.DotNet) + .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 29c3b0dee..c71a2e120 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; @@ -56,7 +59,9 @@ 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, + RateLimitMiddlewareType.Ocelot, + string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .WithUpstreamPathTemplate(upstreamTemplate) .Build(); @@ -98,7 +103,9 @@ public async Task Should_call_middleware_withWhitelistClient() quotaExceededMessage: "Exceeding!", rateLimitCounterPrefix: string.Empty, new RateLimitRule("1s", 100.0D, 3), - (int)HttpStatusCode.TooManyRequests)) + (int)HttpStatusCode.TooManyRequests, + RateLimitMiddlewareType.Ocelot, + string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .Build()) .WithUpstreamHttpMethod(new() { "Get" }) @@ -130,7 +137,9 @@ 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, + RateLimitMiddlewareType.Ocelot, + string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .WithUpstreamPathTemplate(upstreamTemplate) .Build(); @@ -141,7 +150,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 => { @@ -161,7 +172,55 @@ 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", "37")] + public async Task Should_add_EnableRateLimittingAttribute_When_DotNetRateLimiting() + { + // 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, + RateLimitMiddlewareType.DotNet, + "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(); From e6359cb1dbde7d2dc45f285edf2074828b798062 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Wed, 6 Nov 2024 01:10:17 -0600 Subject: [PATCH 03/14] use rate limit status and message in dot net rate limiter --- src/Ocelot/DependencyInjection/Features.cs | 11 ++++++++--- src/Ocelot/DependencyInjection/OcelotBuilder.cs | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Ocelot/DependencyInjection/Features.cs b/src/Ocelot/DependencyInjection/Features.cs index d2b64bfa9..1a3fc6f89 100644 --- a/src/Ocelot/DependencyInjection/Features.cs +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Ocelot.Cache; using Ocelot.Configuration.Creator; @@ -23,20 +24,24 @@ 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) + public static IServiceCollection AddRateLimiting(this IServiceCollection services, IConfiguration configurationRoot) { services .AddSingleton() .AddSingleton(); #if NET7_0_OR_GREATER + 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 = StatusCodes.Status429TooManyRequests; - await rejectedContext.HttpContext.Response.WriteAsync("Too many requests. Please try again later.", token); + rejectedContext.HttpContext.Response.StatusCode = rejectStatusCode; + await rejectedContext.HttpContext.Response.WriteAsync(rejectedMessage, token); }; }); #endif diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index 642697744..968369cb3 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -103,7 +103,7 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); - Services.AddRateLimiting(); // Feature: Rate Limiting + Services.AddRateLimiting(configurationRoot); // Feature: Rate Limiting Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); From 39a4934fa88cf9bbf6e82cc8581b53c33b48b757 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Wed, 6 Nov 2024 01:10:37 -0600 Subject: [PATCH 04/14] added integration test for dotnet rate limiter --- .../Ocelot.IntegrationTests.csproj | 3 + .../RateLimitingTests.cs | 182 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 test/Ocelot.IntegrationTests/RateLimitingTests.cs diff --git a/test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj b/test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj index e09b6ac15..5460117c9 100644 --- a/test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj +++ b/test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj @@ -56,6 +56,7 @@ + @@ -68,6 +69,7 @@ + @@ -80,6 +82,7 @@ + diff --git a/test/Ocelot.IntegrationTests/RateLimitingTests.cs b/test/Ocelot.IntegrationTests/RateLimitingTests.cs new file mode 100644 index 000000000..4bceaa7ef --- /dev/null +++ b/test/Ocelot.IntegrationTests/RateLimitingTests.cs @@ -0,0 +1,182 @@ +#if NET7_0_OR_GREATER +using Microsoft.AspNetCore.RateLimiting; +using System.Threading.RateLimiting; +#endif +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using Ocelot.Configuration; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using System.Net; + +namespace Ocelot.IntegrationTests +{ + public class RateLimitingTests + { + private const string _rateLimitPolicyName = "RateLimitPolicy"; + private const int _rateLimitLimit = 3; + private const string _quotaExceededMessage = "woah!"; + private TestServer _testServer; + private HttpClient _httpClient; + +#if NET7_0_OR_GREATER + [Fact] + public async Task Should_RateLimit() + { + var port = PortFinder.GetRandomPort(); + + var initialConfiguration = new FileConfiguration + { + GlobalConfiguration = + new FileGlobalConfiguration() + { + RateLimitOptions = new FileRateLimitOptions() + { + HttpStatusCode = 429, + QuotaExceededMessage = _quotaExceededMessage, + }, + }, + Routes = new List + { + new() + { + DownstreamHostAndPorts = new List { new() { Host = "localhost", Port = port, } }, + DownstreamScheme = "http", + DownstreamPathTemplate = "/", + UpstreamHttpMethod = new List { "get" }, + UpstreamPathTemplate = "/", + RateLimitOptions = new FileRateLimitRule() + { + EnableRateLimiting = true, + RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, + RateLimitPolicyName = _rateLimitPolicyName, + }, + }, + }, + }; + await GivenThereIsAConfiguration(initialConfiguration); + CreateOcelotServer(); + CreateDownstreamServer($"http://localhost:{port}"); + CreateHttpClient(); + + var responses = await CallMultipleTimes(3); + + responses.ForEach(t => + { + t.StatusCode.ShouldBe(HttpStatusCode.OK); + }); + + responses = await CallMultipleTimes(3); + + foreach (var t in responses) + { + t.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); + var body = await t.Content.ReadAsStringAsync(); + body.ShouldBe(_quotaExceededMessage); + } + } + + private void CreateOcelotServer() + { + var builder = new TestHostBuilder() + .ConfigureAppConfiguration((hostingContext, config) => + { + config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath); + var env = hostingContext.HostingEnvironment; + config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: false); + config.AddJsonFile("ocelot.json", false, false); + config.AddEnvironmentVariables(); + }) + .Configure(app => + { + app.UseOcelot().Wait(); + }) + .ConfigureServices(services => + { + services.AddOcelot(); + services.AddRateLimiter(op => + { + op.AddFixedWindowLimiter(policyName: _rateLimitPolicyName, options => + { + options.PermitLimit = _rateLimitLimit; + options.Window = TimeSpan.FromSeconds(12); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 0; + }); + }); + }) + .UseUrls("http://localhost:5000"); + + _testServer = new TestServer(builder); + } + + private void CreateDownstreamServer(string url) + { + var builder = TestHostBuilder.Create() + .UseUrls(url) + .UseKestrel() + .Configure(app => + { + app.Run(async context => + { + context.Response.StatusCode = 200; + await context.Response.WriteAsync("result"); + }); + }) + .Build(); + + builder.Start(); + } + + private void CreateHttpClient() + { + _httpClient = _testServer.CreateClient(); + } + + private async Task> CallMultipleTimes(int callCount) + { + var responses = new List(); + for (var i = 0; i < callCount; i++) + { + var getResponse = await _httpClient.GetAsync("/"); + responses.Add(getResponse); + } + + return responses; + } + + private static async Task GivenThereIsAConfiguration(FileConfiguration fileConfiguration) + { + var configurationPath = $"{Directory.GetCurrentDirectory()}/ocelot.json"; + + var jsonConfiguration = JsonConvert.SerializeObject(fileConfiguration); + + if (File.Exists(configurationPath)) + { + File.Delete(configurationPath); + } + + await File.WriteAllTextAsync(configurationPath, jsonConfiguration); + + _ = await File.ReadAllTextAsync(configurationPath); + + configurationPath = $"{AppContext.BaseDirectory}/ocelot.json"; + + if (File.Exists(configurationPath)) + { + File.Delete(configurationPath); + } + + await File.WriteAllTextAsync(configurationPath, jsonConfiguration); + + _ = await File.ReadAllTextAsync(configurationPath); + } +#endif + } +} From 2833359cffac88295a30d84545efc1616c7b78a0 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Wed, 6 Nov 2024 01:10:59 -0600 Subject: [PATCH 05/14] added RateLimiter sample --- samples/Ocelot.Samples.sln | 6 +++ .../Ocelot.Samples.RateLimiter.csproj | 13 +++++ .../Ocelot.Samples.RateLimiter.http | 9 ++++ samples/RateLimiter/Program.cs | 26 ++++++++++ .../Properties/launchSettings.json | 41 ++++++++++++++++ .../RateLimiter/appsettings.Development.json | 8 ++++ samples/RateLimiter/appsettings.json | 9 ++++ samples/RateLimiter/ocelot.json | 47 +++++++++++++++++++ 8 files changed, 159 insertions(+) create mode 100644 samples/RateLimiter/Ocelot.Samples.RateLimiter.csproj create mode 100644 samples/RateLimiter/Ocelot.Samples.RateLimiter.http create mode 100644 samples/RateLimiter/Program.cs create mode 100644 samples/RateLimiter/Properties/launchSettings.json create mode 100644 samples/RateLimiter/appsettings.Development.json create mode 100644 samples/RateLimiter/appsettings.json create mode 100644 samples/RateLimiter/ocelot.json 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..0a2df06c8 --- /dev/null +++ b/samples/RateLimiter/Ocelot.Samples.RateLimiter.http @@ -0,0 +1,9 @@ +@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..000ae793c --- /dev/null +++ b/samples/RateLimiter/ocelot.json @@ -0,0 +1,47 @@ +{ + "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, + "RateLimitMiddlewareType": "DotNet", + "RateLimitPolicyName": "fixed" + } + } + ], + "Aggregates": [ + { + "UpstreamPathTemplate": "/", + "RouteKeys": [ "Tom", "Laura" ] + } + ], + "GlobalConfiguration": { + "RateLimitOptions": { + "QuotaExceededMessage": "Customize Tips!", + "HttpStatusCode": 418 // I'm a teapot + } + } +} \ No newline at end of file From 852fac144fab94ff7d3b29a433f05b59e7fd15b1 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Wed, 6 Nov 2024 23:20:07 -0600 Subject: [PATCH 06/14] move rate limiting acceptance test to corresponding folder --- .../RateLimiting/RateLimitingTests.cs | 110 +++++++++++ .../Ocelot.IntegrationTests.csproj | 3 - .../RateLimitingTests.cs | 182 ------------------ .../RateLimitingMiddlewareTests.cs | 2 +- 4 files changed, 111 insertions(+), 186 deletions(-) create mode 100644 test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs delete mode 100644 test/Ocelot.IntegrationTests/RateLimitingTests.cs diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs new file mode 100644 index 000000000..8b6d32828 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs @@ -0,0 +1,110 @@ +#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; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; + +namespace Ocelot.AcceptanceTests.RateLimiting +{ + public class RateLimitingTests: Steps + { + 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)) + .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, + RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, + RateLimitPolicyName = 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.IntegrationTests/Ocelot.IntegrationTests.csproj b/test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj index 5460117c9..e09b6ac15 100644 --- a/test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj +++ b/test/Ocelot.IntegrationTests/Ocelot.IntegrationTests.csproj @@ -56,7 +56,6 @@ - @@ -69,7 +68,6 @@ - @@ -82,7 +80,6 @@ - diff --git a/test/Ocelot.IntegrationTests/RateLimitingTests.cs b/test/Ocelot.IntegrationTests/RateLimitingTests.cs deleted file mode 100644 index 4bceaa7ef..000000000 --- a/test/Ocelot.IntegrationTests/RateLimitingTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -#if NET7_0_OR_GREATER -using Microsoft.AspNetCore.RateLimiting; -using System.Threading.RateLimiting; -#endif -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Newtonsoft.Json; -using Ocelot.Configuration; -using Ocelot.Configuration.File; -using Ocelot.DependencyInjection; -using Ocelot.Middleware; -using System.Net; - -namespace Ocelot.IntegrationTests -{ - public class RateLimitingTests - { - private const string _rateLimitPolicyName = "RateLimitPolicy"; - private const int _rateLimitLimit = 3; - private const string _quotaExceededMessage = "woah!"; - private TestServer _testServer; - private HttpClient _httpClient; - -#if NET7_0_OR_GREATER - [Fact] - public async Task Should_RateLimit() - { - var port = PortFinder.GetRandomPort(); - - var initialConfiguration = new FileConfiguration - { - GlobalConfiguration = - new FileGlobalConfiguration() - { - RateLimitOptions = new FileRateLimitOptions() - { - HttpStatusCode = 429, - QuotaExceededMessage = _quotaExceededMessage, - }, - }, - Routes = new List - { - new() - { - DownstreamHostAndPorts = new List { new() { Host = "localhost", Port = port, } }, - DownstreamScheme = "http", - DownstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "get" }, - UpstreamPathTemplate = "/", - RateLimitOptions = new FileRateLimitRule() - { - EnableRateLimiting = true, - RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, - RateLimitPolicyName = _rateLimitPolicyName, - }, - }, - }, - }; - await GivenThereIsAConfiguration(initialConfiguration); - CreateOcelotServer(); - CreateDownstreamServer($"http://localhost:{port}"); - CreateHttpClient(); - - var responses = await CallMultipleTimes(3); - - responses.ForEach(t => - { - t.StatusCode.ShouldBe(HttpStatusCode.OK); - }); - - responses = await CallMultipleTimes(3); - - foreach (var t in responses) - { - t.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); - var body = await t.Content.ReadAsStringAsync(); - body.ShouldBe(_quotaExceededMessage); - } - } - - private void CreateOcelotServer() - { - var builder = new TestHostBuilder() - .ConfigureAppConfiguration((hostingContext, config) => - { - config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath); - var env = hostingContext.HostingEnvironment; - config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) - .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: false); - config.AddJsonFile("ocelot.json", false, false); - config.AddEnvironmentVariables(); - }) - .Configure(app => - { - app.UseOcelot().Wait(); - }) - .ConfigureServices(services => - { - services.AddOcelot(); - services.AddRateLimiter(op => - { - op.AddFixedWindowLimiter(policyName: _rateLimitPolicyName, options => - { - options.PermitLimit = _rateLimitLimit; - options.Window = TimeSpan.FromSeconds(12); - options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; - options.QueueLimit = 0; - }); - }); - }) - .UseUrls("http://localhost:5000"); - - _testServer = new TestServer(builder); - } - - private void CreateDownstreamServer(string url) - { - var builder = TestHostBuilder.Create() - .UseUrls(url) - .UseKestrel() - .Configure(app => - { - app.Run(async context => - { - context.Response.StatusCode = 200; - await context.Response.WriteAsync("result"); - }); - }) - .Build(); - - builder.Start(); - } - - private void CreateHttpClient() - { - _httpClient = _testServer.CreateClient(); - } - - private async Task> CallMultipleTimes(int callCount) - { - var responses = new List(); - for (var i = 0; i < callCount; i++) - { - var getResponse = await _httpClient.GetAsync("/"); - responses.Add(getResponse); - } - - return responses; - } - - private static async Task GivenThereIsAConfiguration(FileConfiguration fileConfiguration) - { - var configurationPath = $"{Directory.GetCurrentDirectory()}/ocelot.json"; - - var jsonConfiguration = JsonConvert.SerializeObject(fileConfiguration); - - if (File.Exists(configurationPath)) - { - File.Delete(configurationPath); - } - - await File.WriteAllTextAsync(configurationPath, jsonConfiguration); - - _ = await File.ReadAllTextAsync(configurationPath); - - configurationPath = $"{AppContext.BaseDirectory}/ocelot.json"; - - if (File.Exists(configurationPath)) - { - File.Delete(configurationPath); - } - - await File.WriteAllTextAsync(configurationPath, jsonConfiguration); - - _ = await File.ReadAllTextAsync(configurationPath); - } -#endif - } -} diff --git a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs index c71a2e120..71f538aba 100644 --- a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs @@ -174,7 +174,7 @@ await WhenICallTheMiddlewareMultipleTimes(limit, #if NET7_0_OR_GREATER [Fact] - [Trait("Feat", "37")] + [Trait("Feat", "2138")] public async Task Should_add_EnableRateLimittingAttribute_When_DotNetRateLimiting() { // Arrange From 7dbb578e71135a33d0dc4e76b9f07e5f38bcf874 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Wed, 6 Nov 2024 23:33:27 -0600 Subject: [PATCH 07/14] failed test on pipeline run --- test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs index 8b6d32828..3b762d1a9 100644 --- a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs +++ b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs @@ -46,7 +46,7 @@ public void Should_RateLimit() .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/", 1)) .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.TooManyRequests)) .Then(x => x.ThenTheResponseBodyShouldBe(_quotaExceededMessage)) - .And(x => GivenIWait(1000 * _rateLimitWindow)) + .And(x => GivenIWait((1000 * _rateLimitWindow) + 100)) .When(x => x.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/", 1)) .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.OK)) .BDDfy(); From cd3075a19c1667b2cd5ee7e2325d4b421d975ee9 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Tue, 12 Nov 2024 23:23:55 -0600 Subject: [PATCH 08/14] addressed PR comments --- .../Ocelot.Samples.RateLimiter.http | 6 ++- samples/RateLimiter/ocelot.json | 3 +- .../Builder/RateLimitOptionsBuilder.cs | 9 +--- .../Creator/RateLimitOptionsCreator.cs | 3 +- .../Configuration/File/FileRateLimitRule.cs | 21 ++------- .../Configuration/RateLimitMiddlewareType.cs | 7 --- src/Ocelot/Configuration/RateLimitOptions.cs | 10 ++-- .../Validator/RouteFluentValidator.cs | 13 +---- src/Ocelot/DependencyInjection/Features.cs | 24 +++++++--- .../DependencyInjection/OcelotBuilder.cs | 5 +- .../Middleware/RateLimitingMiddleware.cs | 4 +- .../RateLimitingMiddlewareExtensions.cs | 20 +++++++- .../RateLimiting/RateLimitingTests.cs | 3 +- .../DownstreamRouteExtensionsTests.cs | 2 +- .../RateLimitOptionsCreatorTests.cs | 8 ++-- .../Validation/RouteFluentValidatorTests.cs | 47 +------------------ .../DownstreamRouteCreatorTests.cs | 2 +- .../RateLimitingMiddlewareTests.cs | 6 +-- 18 files changed, 67 insertions(+), 126 deletions(-) delete mode 100644 src/Ocelot/Configuration/RateLimitMiddlewareType.cs diff --git a/samples/RateLimiter/Ocelot.Samples.RateLimiter.http b/samples/RateLimiter/Ocelot.Samples.RateLimiter.http index 0a2df06c8..f35158ae9 100644 --- a/samples/RateLimiter/Ocelot.Samples.RateLimiter.http +++ b/samples/RateLimiter/Ocelot.Samples.RateLimiter.http @@ -1,9 +1,11 @@ @RateLimiterSample_HostAddress = http://localhost:5202 -GET {{RateLimiterSample_HostAddress}}/laura/ +GET {{RateLimiterSample_HostAddress}}/laura Accept: application/json -GET {{RateLimiterSample_HostAddress}}/tom/ +### + +GET {{RateLimiterSample_HostAddress}}/tom Accept: application/json ### diff --git a/samples/RateLimiter/ocelot.json b/samples/RateLimiter/ocelot.json index 000ae793c..64b2c4f04 100644 --- a/samples/RateLimiter/ocelot.json +++ b/samples/RateLimiter/ocelot.json @@ -27,8 +27,7 @@ "Key": "Tom", "RateLimitOptions": { "EnableRateLimiting": true, - "RateLimitMiddlewareType": "DotNet", - "RateLimitPolicyName": "fixed" + "Policy": "fixed" } } ], diff --git a/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs b/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs index 7c0b075b3..f9cbda965 100644 --- a/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs +++ b/src/Ocelot/Configuration/Builder/RateLimitOptionsBuilder.cs @@ -10,7 +10,6 @@ public class RateLimitOptionsBuilder private string _rateLimitCounterPrefix; private RateLimitRule _rateLimitRule; private int _httpStatusCode; - private RateLimitMiddlewareType _rateLimitMiddlewareType; private string _rateLimitPolicyName; public RateLimitOptionsBuilder WithEnableRateLimiting(bool enableRateLimiting) @@ -61,12 +60,6 @@ public RateLimitOptionsBuilder WithHttpStatusCode(int httpStatusCode) return this; } - public RateLimitOptionsBuilder WithRateLimitMiddlewareType(RateLimitMiddlewareType middlewareType) - { - _rateLimitMiddlewareType = middlewareType; - return this; - } - public RateLimitOptionsBuilder WithRateLimitPolicyName(string policyName) { _rateLimitPolicyName = policyName; @@ -77,7 +70,7 @@ public RateLimitOptions Build() { return new RateLimitOptions(_enableRateLimiting, _clientIdHeader, _getClientWhitelist, _disableRateLimitHeaders, _quotaExceededMessage, _rateLimitCounterPrefix, - _rateLimitRule, _httpStatusCode, _rateLimitMiddlewareType, _rateLimitPolicyName); + _rateLimitRule, _httpStatusCode, _rateLimitPolicyName); } } } diff --git a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs index 18a1cf39f..cc78b3909 100644 --- a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs @@ -20,8 +20,7 @@ public RateLimitOptions Create(FileRateLimitRule fileRateLimitRule, FileGlobalCo .WithRateLimitRule(new RateLimitRule(fileRateLimitRule.Period, fileRateLimitRule.PeriodTimespan, fileRateLimitRule.Limit)) - .WithRateLimitMiddlewareType(fileRateLimitRule.RateLimitMiddlewareType) - .WithRateLimitPolicyName(fileRateLimitRule.RateLimitPolicyName) + .WithRateLimitPolicyName(fileRateLimitRule.Policy) .Build(); } diff --git a/src/Ocelot/Configuration/File/FileRateLimitRule.cs b/src/Ocelot/Configuration/File/FileRateLimitRule.cs index adb2fcd71..7a79b9995 100644 --- a/src/Ocelot/Configuration/File/FileRateLimitRule.cs +++ b/src/Ocelot/Configuration/File/FileRateLimitRule.cs @@ -14,8 +14,7 @@ public FileRateLimitRule(FileRateLimitRule from) Limit = from.Limit; Period = from.Period; PeriodTimespan = from.PeriodTimespan; - RateLimitMiddlewareType = from.RateLimitMiddlewareType; - RateLimitPolicyName = from.RateLimitPolicyName; + Policy = from.Policy; } /// @@ -58,21 +57,13 @@ public FileRateLimitRule(FileRateLimitRule from) /// public long Limit { get; set; } - /// - /// Rate limit middleware type. - /// - /// - /// , - /// - public RateLimitMiddlewareType RateLimitMiddlewareType { 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 RateLimitPolicyName { get; set; } + public string Policy { get; set; } /// public override string ToString() @@ -84,13 +75,11 @@ public override string ToString() var sb = new StringBuilder(); - sb.Append($"{nameof(RateLimitMiddlewareType)}:{RateLimitMiddlewareType},"); - - if (RateLimitMiddlewareType == RateLimitMiddlewareType.DotNet) + if (!string.IsNullOrWhiteSpace(Policy)) { - sb.Append($"{nameof(RateLimitPolicyName)}:{RateLimitPolicyName}"); + sb.Append($"{nameof(Policy)}:{Policy}"); } - else if (RateLimitMiddlewareType == RateLimitMiddlewareType.Ocelot) + else { sb.Append( $"{nameof(Period)}:{Period},{nameof(PeriodTimespan)}:{PeriodTimespan:F},{nameof(Limit)}:{Limit},{nameof(ClientWhitelist)}:["); diff --git a/src/Ocelot/Configuration/RateLimitMiddlewareType.cs b/src/Ocelot/Configuration/RateLimitMiddlewareType.cs deleted file mode 100644 index 3a4e14ace..000000000 --- a/src/Ocelot/Configuration/RateLimitMiddlewareType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Ocelot.Configuration; - -public enum RateLimitMiddlewareType -{ - Ocelot, - DotNet, -} diff --git a/src/Ocelot/Configuration/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs index 8e8539141..06b387842 100644 --- a/src/Ocelot/Configuration/RateLimitOptions.cs +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -8,8 +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, - RateLimitMiddlewareType rateLimitMiddlewareType, string rateLimitPolicyName) + string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode, string rateLimitPolicy = null) { EnableRateLimiting = enableRateLimiting; ClientIdHeader = clientIdHeader; @@ -19,8 +18,7 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func @@ -90,8 +88,6 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func public bool DisableRateLimitHeaders { get; } - public RateLimitMiddlewareType RateLimitMiddlewareType { get; } - - public string RateLimiterPolicyName { get; } + public string Policy { get; } } } diff --git a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs index f880b1f65..e241627e0 100644 --- a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs @@ -66,12 +66,6 @@ public RouteFluentValidator(IAuthenticationSchemeProvider authenticationSchemePr .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"); }); - - When(IsDotNetRateLimiter, () => { - RuleFor(route => route.RateLimitOptions.RateLimitPolicyName) - .NotEmpty() - .WithMessage("RateLimitOptions.RateLimitPolicyName is required when RateLimitOptions.RateLimitMiddlewareType is DotNet."); - }); }); RuleFor(route => route.AuthenticationOptions) @@ -118,12 +112,7 @@ private async Task IsSupportedAuthenticationProviders(FileAuthenticationOp private static bool IsOcelotRateLimiter(FileRoute fileRoute) { - return fileRoute.RateLimitOptions.RateLimitMiddlewareType == RateLimitMiddlewareType.Ocelot; - } - - private static bool IsDotNetRateLimiter(FileRoute fileRoute) - { - return fileRoute.RateLimitOptions.RateLimitMiddlewareType == RateLimitMiddlewareType.DotNet; + return string.IsNullOrWhiteSpace(fileRoute.RateLimitOptions.Policy); } private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions) diff --git a/src/Ocelot/DependencyInjection/Features.cs b/src/Ocelot/DependencyInjection/Features.cs index 1a3fc6f89..aadfe949c 100644 --- a/src/Ocelot/DependencyInjection/Features.cs +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -26,13 +26,22 @@ public static class Features /// The services collection to add the feature to. /// Root configuration object. /// The same object. - public static IServiceCollection AddRateLimiting(this IServiceCollection services, IConfiguration configurationRoot) - { - services - .AddSingleton() - .AddSingleton(); - + 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!"; @@ -44,9 +53,10 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service await rejectedContext.HttpContext.Response.WriteAsync(rejectedMessage, token); }; }); -#endif + return services; } +#endif /// /// Ocelot feature: Request Caching. diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index 968369cb3..505ff064e 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -103,7 +103,10 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); - Services.AddRateLimiting(configurationRoot); // Feature: Rate Limiting + 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 b42b0458d..2ab3952ca 100644 --- a/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs +++ b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs @@ -39,10 +39,10 @@ public async Task Invoke(HttpContext httpContext) } #if NET7_0_OR_GREATER - if (options.RateLimitMiddlewareType == RateLimitMiddlewareType.DotNet) + 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.RateLimiterPolicyName)); + var metadata = new EndpointMetadataCollection(new EnableRateLimitingAttribute(options.Policy)); var endpoint = new Endpoint(null, metadata, "tempEndpoint"); httpContext.SetEndpoint(endpoint); await _next.Invoke(httpContext); 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/RateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs index 3b762d1a9..bc6f544fe 100644 --- a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs +++ b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs @@ -62,8 +62,7 @@ public void Should_RateLimit() RateLimitOptions = new FileRateLimitRule() { EnableRateLimiting = true, - RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, - RateLimitPolicyName = rateLimitPolicyName, + Policy = rateLimitPolicyName, }, }; diff --git a/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs b/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs index b287c218e..0c3b3bcc3 100644 --- a/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs +++ b/test/Ocelot.UnitTests/Configuration/DownstreamRouteExtensionsTests.cs @@ -31,7 +31,7 @@ public DownstreamRouteExtensionsTests() default, new CacheOptions(0, null, null, null), new LoadBalancerOptions(null, null, 0), - new RateLimitOptions(false, null, null, false, null, null, null, 0, RateLimitMiddlewareType.Ocelot, null), + new RateLimitOptions(false, null, null, false, null, null, null, 0), new Dictionary(), new List(), new List(), diff --git a/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs index d455a66f0..3013f708f 100644 --- a/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/RateLimitOptionsCreatorTests.cs @@ -54,7 +54,6 @@ public void should_create_rate_limit_options_ocelot() .WithRateLimitRule(new RateLimitRule(fileRoute.RateLimitOptions.Period, fileRoute.RateLimitOptions.PeriodTimespan, fileRoute.RateLimitOptions.Limit)) - .WithRateLimitMiddlewareType(RateLimitMiddlewareType.Ocelot) .Build(); _enabled = false; @@ -68,14 +67,14 @@ public void should_create_rate_limit_options_ocelot() } [Fact] - public void should_create_rate_limit_options_dotnet() + [Trait("Feat", "2138")] + public void should_create_rate_limit_options_aspnet() { var fileRoute = new FileRoute { RateLimitOptions = new FileRateLimitRule { - RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, - RateLimitPolicyName = "test", + Policy = "test", EnableRateLimiting = true, }, }; @@ -98,7 +97,6 @@ public void should_create_rate_limit_options_dotnet() .WithQuotaExceededMessage("QuotaExceededMessage") .WithRateLimitCounterPrefix("ocelot") .WithRateLimitRule(new RateLimitRule(null, 0, 0)) - .WithRateLimitMiddlewareType(RateLimitMiddlewareType.DotNet) .WithRateLimitPolicyName("test") .Build(); diff --git a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs index b05a4b0e4..257c054dd 100644 --- a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs @@ -154,7 +154,7 @@ public void upstream_path_template_should_not_contain_scheme(string upstreamPath } [Fact] - public void should_not_be_valid_if_enable_rate_limiting_true_type_ocelot_and_period_is_empty() + public void should_not_be_valid_if_enable_rate_limiting_true_and_period_is_empty() { var fileRoute = new FileRoute { @@ -174,7 +174,7 @@ public void should_not_be_valid_if_enable_rate_limiting_true_type_ocelot_and_per } [Fact] - public void should_not_be_valid_if_enable_rate_limiting_true_type_ocelot_and_period_has_value() + public void should_not_be_valid_if_enable_rate_limiting_true_and_period_has_value() { var fileRoute = new FileRoute { @@ -194,49 +194,6 @@ public void should_not_be_valid_if_enable_rate_limiting_true_type_ocelot_and_per .BDDfy(); } - [Fact] - public void should_not_be_valid_if_enable_rate_limiting_true_type_dotnet_and_policy_is_empty() - { - var fileRoute = new FileRoute - { - DownstreamPathTemplate = "/test", - UpstreamPathTemplate = "/test", - RateLimitOptions = new FileRateLimitRule - { - EnableRateLimiting = true, - RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, - }, - }; - - this.Given(_ => GivenThe(fileRoute)) - .When(_ => WhenIValidate()) - .Then(_ => ThenTheResultIsInvalid()) - .And(_ => ThenTheErrorsContains("RateLimitOptions.RateLimitPolicyName is required when RateLimitOptions.RateLimitMiddlewareType is DotNet.")) - .BDDfy(); - } - - [Fact] - public void should_be_valid_if_enable_rate_limiting_true_type_dotnet_and_policy_has_value() - { - var fileRoute = new FileRoute - { - DownstreamPathTemplate = "/test", - UpstreamPathTemplate = "/test", - ServiceName = "Lads", - RateLimitOptions = new FileRateLimitRule - { - EnableRateLimiting = true, - RateLimitMiddlewareType = RateLimitMiddlewareType.DotNet, - RateLimitPolicyName = "testPolicy", - }, - }; - - this.Given(_ => GivenThe(fileRoute)) - .When(_ => WhenIValidate()) - .Then(_ => ThenTheResultIsValid()) - .BDDfy(); - } - [Theory] [InlineData(null, false)] [InlineData("", false)] diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs index d39186791..4c2deb5fd 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs @@ -58,12 +58,12 @@ 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") - .WithRateLimitMiddlewareType(RateLimitMiddlewareType.DotNet) .WithRateLimitPolicyName("test") .Build(); diff --git a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs index 71f538aba..0034169cb 100644 --- a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs @@ -60,7 +60,6 @@ public async Task Should_call_middleware_and_ratelimiting() rateLimitCounterPrefix: string.Empty, new RateLimitRule("1s", 100.0D, limit), (int)HttpStatusCode.TooManyRequests, - RateLimitMiddlewareType.Ocelot, string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .WithUpstreamPathTemplate(upstreamTemplate) @@ -104,7 +103,6 @@ public async Task Should_call_middleware_withWhitelistClient() rateLimitCounterPrefix: string.Empty, new RateLimitRule("1s", 100.0D, 3), (int)HttpStatusCode.TooManyRequests, - RateLimitMiddlewareType.Ocelot, string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .Build()) @@ -138,7 +136,6 @@ public async Task MiddlewareInvoke_PeriodTimespanValueIsGreaterThanPeriod_Status rateLimitCounterPrefix: string.Empty, new RateLimitRule("1s", 30.0D, limit), // bug scenario (int)HttpStatusCode.TooManyRequests, - RateLimitMiddlewareType.Ocelot, string.Empty)) .WithUpstreamHttpMethod(new() { "Get" }) .WithUpstreamPathTemplate(upstreamTemplate) @@ -175,7 +172,7 @@ await WhenICallTheMiddlewareMultipleTimes(limit, #if NET7_0_OR_GREATER [Fact] [Trait("Feat", "2138")] - public async Task Should_add_EnableRateLimittingAttribute_When_DotNetRateLimiting() + public async Task Should_add_EnableRateLimittingAttribute_When_AspNetRateLimiting() { // Arrange const long limit = 3L; @@ -192,7 +189,6 @@ public async Task Should_add_EnableRateLimittingAttribute_When_DotNetRateLimitin rateLimitCounterPrefix: null, null, (int)HttpStatusCode.TooManyRequests, - RateLimitMiddlewareType.DotNet, "testPolicy")) .WithUpstreamHttpMethod(new() { "Get" }) .WithUpstreamPathTemplate(upstreamTemplate) From bbe193125041d90d4e25bdc8c3d9915452b859f1 Mon Sep 17 00:00:00 2001 From: jlukawska <56401969+jlukawska@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:09:30 +0100 Subject: [PATCH 09/14] #1305 Populate RateLimiting headers in the original `HttpContext` response accessed via `IHttpContextAccessor` (#1307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * set rate limiting headers on the proper httpcontext * fix Retry-After header * merge fix * refactor of ClientRateLimitTests * merge fix * Fix build after rebasing * EOL: test/Ocelot.AcceptanceTests/Steps.cs * Add `RateLimitingSteps` * code review by @raman-m * Inject IHttpContextAccessor, not IServiceProvider * Ocelot's rate-limiting headers have become legacy * Headers definition life hack * A good StackOverflow link --------- Co-authored-by: Jolanta Łukawska Co-authored-by: Raman Maksimchuk --- src/Ocelot/Configuration/RateLimitOptions.cs | 6 +- .../Middleware/RateLimitingMiddleware.cs | 57 ++++++++++--------- .../RateLimiting/RateLimitingHeaders.cs | 32 +++++++++++ .../RateLimiting/ClientRateLimitingTests.cs | 51 ++++++++++++++++- .../RateLimiting/RateLimitingSteps.cs | 15 +++++ .../ConsulConfigurationInConsulTests.cs | 3 +- test/Ocelot.AcceptanceTests/Steps.cs | 11 ---- .../RateLimitingMiddlewareTests.cs | 4 +- 8 files changed, 136 insertions(+), 43 deletions(-) create mode 100644 src/Ocelot/RateLimiting/RateLimitingHeaders.cs create mode 100644 test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingSteps.cs diff --git a/src/Ocelot/Configuration/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs index 06b387842..243d1574b 100644 --- a/src/Ocelot/Configuration/RateLimitOptions.cs +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -33,7 +33,7 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func /// - /// A collection with white listed clients. + /// A (where T is ) collection with white listed clients. /// public List ClientWhitelist => _getClientWhitelist(); @@ -81,10 +81,10 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func - /// Disables X-Rate-Limit and Rety-After headers. + /// Disables X-Rate-Limit and Retry-After headers. /// /// - /// A boolean value for disabling X-Rate-Limit and Rety-After headers. + /// A boolean value for disabling X-Rate-Limit and Retry-After headers. /// public bool DisableRateLimitHeaders { get; } diff --git a/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs index 2ab3952ca..40d4c2cc1 100644 --- a/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs +++ b/src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs @@ -1,7 +1,8 @@ -#if NET7_0_OR_GREATER +#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; using Ocelot.Middleware; @@ -13,15 +14,18 @@ public class RateLimitingMiddleware : OcelotMiddleware { private readonly RequestDelegate _next; private readonly IRateLimiting _limiter; + private readonly IHttpContextAccessor _contextAccessor; public RateLimitingMiddleware( RequestDelegate next, IOcelotLoggerFactory factory, - IRateLimiting limiter) + IRateLimiting limiter, + IHttpContextAccessor contextAccessor) : base(factory.CreateLogger()) { _next = next; _limiter = limiter; + _contextAccessor = contextAccessor; } public async Task Invoke(HttpContext httpContext) @@ -83,11 +87,15 @@ public async Task Invoke(HttpContext httpContext) } } - //set X-Rate-Limit headers for the longest period + // Set X-Rate-Limit headers for the longest period if (!options.DisableRateLimitHeaders) { - var headers = _limiter.GetHeaders(httpContext, identity, options); - httpContext.Response.OnStarting(SetRateLimitHeaders, state: headers); + var originalContext = _contextAccessor?.HttpContext; + if (originalContext != null) + { + var headers = _limiter.GetHeaders(originalContext, identity, options); + originalContext.Response.OnStarting(SetRateLimitHeaders, state: headers); + } } await _next.Invoke(httpContext); @@ -108,15 +116,8 @@ public virtual ClientRequestIdentity SetIdentity(HttpContext httpContext, RateLi ); } - public bool IsWhitelisted(ClientRequestIdentity requestIdentity, RateLimitOptions option) - { - if (option.ClientWhitelist.Contains(requestIdentity.ClientId)) - { - return true; - } - - return false; - } + public static bool IsWhitelisted(ClientRequestIdentity requestIdentity, RateLimitOptions option) + => option.ClientWhitelist.Contains(requestIdentity.ClientId); public virtual void LogBlockedRequest(HttpContext httpContext, ClientRequestIdentity identity, RateLimitCounter counter, RateLimitRule rule, DownstreamRoute downstreamRoute) { @@ -127,14 +128,15 @@ public virtual void LogBlockedRequest(HttpContext httpContext, ClientRequestIden public virtual DownstreamResponse ReturnQuotaExceededResponse(HttpContext httpContext, RateLimitOptions option, string retryAfter) { var message = GetResponseMessage(option); - - var http = new HttpResponseMessage((HttpStatusCode)option.HttpStatusCode); - - http.Content = new StringContent(message); + var http = new HttpResponseMessage((HttpStatusCode)option.HttpStatusCode) + { + Content = new StringContent(message), + }; if (!option.DisableRateLimitHeaders) { - http.Headers.TryAddWithoutValidation("Retry-After", retryAfter); // in seconds, not date string + http.Headers.TryAddWithoutValidation(HeaderNames.RetryAfter, retryAfter); // in seconds, not date string + httpContext.Response.Headers[HeaderNames.RetryAfter] = retryAfter; } return new DownstreamResponse(http); @@ -148,14 +150,17 @@ private static string GetResponseMessage(RateLimitOptions option) return message; } - private static Task SetRateLimitHeaders(object rateLimitHeaders) + /// TODO: Produced Ocelot's headers don't follow industry standards. + /// More details in docs. + /// Captured state as a object. + /// The object. + private static Task SetRateLimitHeaders(object state) { - var headers = (RateLimitHeaders)rateLimitHeaders; - - headers.Context.Response.Headers["X-Rate-Limit-Limit"] = headers.Limit; - headers.Context.Response.Headers["X-Rate-Limit-Remaining"] = headers.Remaining; - headers.Context.Response.Headers["X-Rate-Limit-Reset"] = headers.Reset; - + var limitHeaders = (RateLimitHeaders)state; + var headers = limitHeaders.Context.Response.Headers; + headers[RateLimitingHeaders.X_Rate_Limit_Limit] = limitHeaders.Limit; + headers[RateLimitingHeaders.X_Rate_Limit_Remaining] = limitHeaders.Remaining; + headers[RateLimitingHeaders.X_Rate_Limit_Reset] = limitHeaders.Reset; return Task.CompletedTask; } } diff --git a/src/Ocelot/RateLimiting/RateLimitingHeaders.cs b/src/Ocelot/RateLimiting/RateLimitingHeaders.cs new file mode 100644 index 000000000..7515efc2b --- /dev/null +++ b/src/Ocelot/RateLimiting/RateLimitingHeaders.cs @@ -0,0 +1,32 @@ +using Microsoft.Net.Http.Headers; + +namespace Ocelot.RateLimiting; + +/// +/// TODO These Ocelot's RateLimiting headers don't follow industry standards, see links. +/// +/// Links: +/// +/// GitHub: draft-polli-ratelimit-headers +/// GitHub: ratelimit-headers +/// GitHub Wiki: RateLimit header fields for HTTP +/// StackOverflow: Examples of HTTP API Rate Limiting HTTP Response headers +/// +/// +public static class RateLimitingHeaders +{ + public const char Dash = '-'; + public const char Underscore = '_'; + + /// Gets the Retry-After HTTP header name. + public static readonly string Retry_After = HeaderNames.RetryAfter; + + /// Gets the X-Rate-Limit-Limit Ocelot's header name. + public static readonly string X_Rate_Limit_Limit = nameof(X_Rate_Limit_Limit).Replace(Underscore, Dash); + + /// Gets the X-Rate-Limit-Remaining Ocelot's header name. + public static readonly string X_Rate_Limit_Remaining = nameof(X_Rate_Limit_Remaining).Replace(Underscore, Dash); + + /// Gets the X-Rate-Limit-Reset Ocelot's header name. + public static readonly string X_Rate_Limit_Reset = nameof(X_Rate_Limit_Reset).Replace(Underscore, Dash); +} diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/ClientRateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/ClientRateLimitingTests.cs index da4fff97b..4d31598b7 100644 --- a/test/Ocelot.AcceptanceTests/RateLimiting/ClientRateLimitingTests.cs +++ b/test/Ocelot.AcceptanceTests/RateLimiting/ClientRateLimitingTests.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; using Ocelot.Configuration.File; +using Ocelot.RateLimiting; namespace Ocelot.AcceptanceTests.RateLimiting; -public sealed class ClientRateLimitingTests : Steps, IDisposable +public sealed class ClientRateLimitingTests : RateLimitingSteps, IDisposable { const int OK = (int)HttpStatusCode.OK; const int TooManyRequests = (int)HttpStatusCode.TooManyRequests; @@ -129,6 +131,53 @@ public void StatusShouldNotBeEqualTo429_PeriodTimespanValueIsGreaterThanPeriod() .And(x => ThenTheResponseBodyShouldBe("101")) // total 101 OK responses .BDDfy(); } + + [Theory] + [Trait("Bug", "1305")] + [InlineData(false)] + [InlineData(true)] + public void Should_set_ratelimiting_headers_on_response_when_DisableRateLimitHeaders_set_to(bool disableRateLimitHeaders) + { + int port = PortFinder.GetRandomPort(); + var configuration = CreateConfigurationForCheckingHeaders(port, disableRateLimitHeaders); + bool exist = !disableRateLimitHeaders; + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/api/ClientRateLimit")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 1)) + .Then(x => ThenRateLimitingHeadersExistInResponse(exist)) + .And(x => ThenRetryAfterHeaderExistsInResponse(false)) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 2)) + .Then(x => ThenRateLimitingHeadersExistInResponse(exist)) + .And(x => ThenRetryAfterHeaderExistsInResponse(false)) + .When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 1)) + .Then(x => ThenRateLimitingHeadersExistInResponse(false)) + .And(x => ThenRetryAfterHeaderExistsInResponse(exist)) + .BDDfy(); + } + + private FileConfiguration CreateConfigurationForCheckingHeaders(int port, bool disableRateLimitHeaders) + { + var route = GivenRoute(port, null, null, new(), 3, "100s", 1000.0D); + var config = GivenConfiguration(route); + config.GlobalConfiguration.RateLimitOptions = new FileRateLimitOptions() + { + DisableRateLimitHeaders = disableRateLimitHeaders, + QuotaExceededMessage = "", + HttpStatusCode = TooManyRequests, + }; + return config; + } + + private void ThenRateLimitingHeadersExistInResponse(bool headersExist) + { + _response.Headers.Contains(RateLimitingHeaders.X_Rate_Limit_Limit).ShouldBe(headersExist); + _response.Headers.Contains(RateLimitingHeaders.X_Rate_Limit_Remaining).ShouldBe(headersExist); + _response.Headers.Contains(RateLimitingHeaders.X_Rate_Limit_Reset).ShouldBe(headersExist); + } + + private void ThenRetryAfterHeaderExistsInResponse(bool headersExist) + => _response.Headers.Contains(HeaderNames.RetryAfter).ShouldBe(headersExist); private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath) { diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingSteps.cs b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingSteps.cs new file mode 100644 index 000000000..5db54e2bb --- /dev/null +++ b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingSteps.cs @@ -0,0 +1,15 @@ +namespace Ocelot.AcceptanceTests.RateLimiting; + +public class RateLimitingSteps : Steps +{ + public async Task WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit(string url, int times) + { + for (var i = 0; i < times; i++) + { + const string clientId = "ocelotclient1"; + var request = new HttpRequestMessage(new HttpMethod("GET"), url); + request.Headers.Add("ClientId", clientId); + _response = await _ocelotClient.SendAsync(request); + } + } +} diff --git a/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs index 60ccb0559..5c781e580 100644 --- a/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Newtonsoft.Json; +using Ocelot.AcceptanceTests.RateLimiting; using Ocelot.Cache; using Ocelot.Configuration.File; using Ocelot.DependencyInjection; @@ -14,7 +15,7 @@ namespace Ocelot.AcceptanceTests.ServiceDiscovery { - public sealed class ConsulConfigurationInConsulTests : Steps, IDisposable + public sealed class ConsulConfigurationInConsulTests : RateLimitingSteps, IDisposable { private IWebHost _builder; private IWebHost _fakeConsulBuilder; diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs index ec1808906..9c27b9aea 100644 --- a/test/Ocelot.AcceptanceTests/Steps.cs +++ b/test/Ocelot.AcceptanceTests/Steps.cs @@ -733,17 +733,6 @@ public static async Task WhenIDoActionMultipleTimes(int times, Func a await action.Invoke(i); } - public async Task WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit(string url, int times) - { - for (var i = 0; i < times; i++) - { - const string clientId = "ocelotclient1"; - var request = new HttpRequestMessage(new HttpMethod("GET"), url); - request.Headers.Add("ClientId", clientId); - _response = await _ocelotClient.SendAsync(request); - } - } - public async Task WhenIGetUrlOnTheApiGateway(string url, string requestId) { _ocelotClient.DefaultRequestHeaders.TryAddWithoutValidation(RequestIdKey, requestId); diff --git a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs index 0034169cb..759c307b0 100644 --- a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs @@ -21,6 +21,7 @@ public class RateLimitingMiddlewareTests : UnitTest private readonly IRateLimitStorage _storage; private readonly Mock _loggerFactory; private readonly Mock _logger; + private readonly Mock _contextAccessor; private readonly RateLimitingMiddleware _middleware; private readonly RequestDelegate _next; private readonly IRateLimiting _rateLimiting; @@ -37,7 +38,8 @@ public RateLimitingMiddlewareTests() _loggerFactory.Setup(x => x.CreateLogger()).Returns(_logger.Object); _next = context => Task.CompletedTask; _rateLimiting = new _RateLimiting_(_storage); - _middleware = new RateLimitingMiddleware(_next, _loggerFactory.Object, _rateLimiting); + _contextAccessor = new Mock(); + _middleware = new RateLimitingMiddleware(_next, _loggerFactory.Object, _rateLimiting, _contextAccessor.Object); _downstreamResponses = new(); } From 032e7f2780df7321bb814c87b3fdcf9c16b3b9d6 Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Tue, 12 Nov 2024 22:18:20 -0600 Subject: [PATCH 10/14] Update test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs Co-authored-by: Raman Maksimchuk --- test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs index bc6f544fe..7349ecd3f 100644 --- a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs +++ b/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs @@ -11,7 +11,7 @@ namespace Ocelot.AcceptanceTests.RateLimiting { - public class RateLimitingTests: Steps + public class AspNetRateLimitingTests: Steps { private const string _rateLimitPolicyName = "RateLimitPolicy"; private const int _rateLimitLimit = 3; From e8987ad02d1adbd06939ff0a8ac4b26900eedb5a Mon Sep 17 00:00:00 2001 From: Yhlas Jorayev Date: Wed, 13 Nov 2024 00:11:44 -0600 Subject: [PATCH 11/14] rename test file name to AspNetRateLimitingTests --- .../{RateLimitingTests.cs => AspNetRateLimitingTests.cs} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename test/Ocelot.AcceptanceTests/RateLimiting/{RateLimitingTests.cs => AspNetRateLimitingTests.cs} (98%) diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs similarity index 98% rename from test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs rename to test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs index 7349ecd3f..628fae452 100644 --- a/test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs +++ b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs @@ -5,13 +5,12 @@ #endif using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; using Ocelot.Configuration.File; using Ocelot.DependencyInjection; namespace Ocelot.AcceptanceTests.RateLimiting { - public class AspNetRateLimitingTests: Steps + public class AspNetRateLimitingTests: RateLimitingSteps { private const string _rateLimitPolicyName = "RateLimitPolicy"; private const int _rateLimitLimit = 3; From 2c01ab17b17e500b8cc49166d61e78a7683a017c Mon Sep 17 00:00:00 2001 From: Raman Maksimchuk Date: Wed, 24 Sep 2025 16:44:52 +0300 Subject: [PATCH 12/14] Incorporate the proposed code into the middleware after resolving merge conflicts --- .../RateLimiting/RateLimitingMiddleware.cs | 11 ++ .../RateLimitingMiddlewareExtensions.cs | 29 ++++ .../RateLimiting/AspNetRateLimitingTests.cs | 147 +++++++----------- .../RateLimitingMiddlewareTests.cs | 42 ++--- 4 files changed, 109 insertions(+), 120 deletions(-) create mode 100644 src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs diff --git a/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs b/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs index 690b83f67..2bcbc0187 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,16 @@ public Task Invoke(HttpContext context) return _next.Invoke(context); } +#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"); + context.SetEndpoint(endpoint); + return _next.Invoke(context); + } +#endif 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..608ac4e77 --- /dev/null +++ b/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs @@ -0,0 +1,29 @@ +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 +#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 index 628fae452..2d9803db8 100644 --- a/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs +++ b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs @@ -1,108 +1,73 @@ -#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 Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; using Ocelot.Configuration.File; using Ocelot.DependencyInjection; +using System.Threading.RateLimiting; -namespace Ocelot.AcceptanceTests.RateLimiting +namespace Ocelot.AcceptanceTests.RateLimiting; + +public class AspNetRateLimitingTests: RateLimitingSteps { - public class AspNetRateLimitingTests: RateLimitingSteps + private const string _rateLimitPolicyName = "RateLimitPolicy"; + private const int _rateLimitLimit = 3; + private const int _rateLimitWindow = 1; + private const string _quotaExceededMessage = "woah!"; + + [Fact] + [Trait("Feat", "2138")] + public async Task Should_RateLimit() { - 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 port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, _rateLimitPolicyName); + var configuration = GivenConfiguration(route); + GivenThereIsAServiceRunningOnPath(port, "/"); + GivenThereIsAConfiguration(configuration); + GivenOcelotIsRunning(WithRateLimiter); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); + ThenTheStatusCodeShouldBeOK(); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 2); + ThenTheStatusCodeShouldBeOK(); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); + ThenTheStatusCodeShouldBe(HttpStatusCode.TooManyRequests); + ThenTheResponseBodyShouldBe(_quotaExceededMessage); + GivenIWait((1000 * _rateLimitWindow) + 100); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); + ThenTheStatusCodeShouldBeOK(); + } - 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() + private FileRoute GivenRoute(int port, string rateLimitPolicyName) + { + var route = GivenRoute(port); + route.RateLimitOptions = new() { - DownstreamHostAndPorts = new() { new("localhost", port) }, - DownstreamPathTemplate = "/", - DownstreamScheme = Uri.UriSchemeHttp, - UpstreamHttpMethod = new() { HttpMethods.Get }, - UpstreamPathTemplate = "/", - RateLimitOptions = new FileRateLimitRule() - { - EnableRateLimiting = true, - Policy = rateLimitPolicyName, - }, + 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; - } + return route; + } - private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath) + private FileConfiguration GivenConfiguration(params FileRoute[] routes) + { + var config = base.GivenConfiguration(routes); + config.GlobalConfiguration.RateLimitOptions = new() { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, context => - { - context.Response.StatusCode = (int)HttpStatusCode.OK; - context.Response.WriteAsync("test response"); - return Task.CompletedTask; - }); - } + QuotaExceededMessage = _quotaExceededMessage, + HttpStatusCode = StatusCodes.Status429TooManyRequests, + }; + return config; + } - private Action GivenOcelotServices() => services => + private void WithRateLimiter(IServiceCollection services) => services + .AddOcelot().Services + .AddRateLimiter(op => { - services.AddOcelot(); - services.AddRateLimiter(op => + op.AddFixedWindowLimiter(_rateLimitPolicyName, options => { - op.AddFixedWindowLimiter(_rateLimitPolicyName, options => - { - options.PermitLimit = _rateLimitLimit; - options.Window = TimeSpan.FromSeconds(_rateLimitWindow); - options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; - options.QueueLimit = 0; - }); + options.PermitLimit = _rateLimitLimit; + options.Window = TimeSpan.FromSeconds(_rateLimitWindow); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 0; }); - }; -#endif - } + }); } diff --git a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs index 1e6611f9f..a046b16a9 100644 --- a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs @@ -260,51 +260,35 @@ public async Task Invoke_NoClientHeader_Status503_ShouldLogWarning() _logger.Verify(x => x.LogWarning(err.Message), Times.Once); } -#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); + 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, Assert - var contexts = await WhenICallTheMiddlewareMultipleTimes(limit+1, downstreamRouteHolder); + // 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"); }); - } -#endif private static RateLimitOptions GivenRateLimitOptions(RateLimitRule rule = null, [CallerMemberName] string testName = null) => new( enableRateLimiting: true, From e5555ed48290d258397a4d46d35dd52ccfceda25 Mon Sep 17 00:00:00 2001 From: Raman Maksimchuk Date: Wed, 24 Sep 2025 17:15:04 +0300 Subject: [PATCH 13/14] Propagate the Policy name option into the middleware --- .../Creator/RateLimitOptionsCreator.cs | 1 + src/Ocelot/Configuration/RateLimitOptions.cs | 2 +- .../RateLimiting/RateLimitingMiddleware.cs | 7 +++---- .../RateLimiting/AspNetRateLimitingTests.cs | 17 ++++++++++------- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs index 69193f3aa..26f043c4b 100644 --- a/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/RateLimitOptionsCreator.cs @@ -79,6 +79,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/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs index ee14f99ab..1b5860dbc 100644 --- a/src/Ocelot/Configuration/RateLimitOptions.cs +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -65,7 +65,7 @@ public RateLimitOptions(FileRateLimitByHeaderRule fromRule) fromRule.Period.IfEmpty(RateLimitRule.DefaultPeriod), fromRule.PeriodTimespan.HasValue ? $"{fromRule.PeriodTimespan.Value}s" : fromRule.Wait, fromRule.Limit ?? RateLimitRule.ZeroLimit); - Policy = default; + Policy = fromRule.Policy; } public RateLimitOptions(RateLimitOptions fromOptions) diff --git a/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs b/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs index 2bcbc0187..89bbdd6b2 100644 --- a/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs +++ b/src/Ocelot/RateLimiting/RateLimitingMiddleware.cs @@ -40,16 +40,15 @@ public Task Invoke(HttpContext context) return _next.Invoke(context); } -#if NET7_0_OR_GREATER - if (!string.IsNullOrWhiteSpace(options.Policy)) + if (!options.Policy.IsNullOrEmpty()) { - //add EnableRateLimiting attribute to endpoint, so that .Net rate limiter can pick it up and do its thing + // 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); } -#endif + var identity = Identify(context, options, downstreamRoute); if (IsWhitelisted(identity, options)) { diff --git a/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs index 2d9803db8..c0b0a00bb 100644 --- a/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs +++ b/test/Ocelot.AcceptanceTests/RateLimiting/AspNetRateLimitingTests.cs @@ -10,24 +10,27 @@ 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 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, _rateLimitPolicyName); + var route = GivenRoute(port, FixedWindowPolicyName); var configuration = GivenConfiguration(route); GivenThereIsAServiceRunningOnPath(port, "/"); GivenThereIsAConfiguration(configuration); GivenOcelotIsRunning(WithRateLimiter); await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); ThenTheStatusCodeShouldBeOK(); - await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 2); + await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", _rateLimitLimit - 1); ThenTheStatusCodeShouldBeOK(); await WhenIGetUrlOnTheApiGatewayMultipleTimes("/", 1); ThenTheStatusCodeShouldBe(HttpStatusCode.TooManyRequests); @@ -62,7 +65,7 @@ private void WithRateLimiter(IServiceCollection services) => services .AddOcelot().Services .AddRateLimiter(op => { - op.AddFixedWindowLimiter(_rateLimitPolicyName, options => + op.AddFixedWindowLimiter(FixedWindowPolicyName, options => { options.PermitLimit = _rateLimitLimit; options.Window = TimeSpan.FromSeconds(_rateLimitWindow); From be8d67b064a2c9a1d9fafe7d3dbfd61ec1030e91 Mon Sep 17 00:00:00 2001 From: Raman Maksimchuk Date: Sun, 18 Jan 2026 17:06:49 +0300 Subject: [PATCH 14/14] Delete directive: #if NET7_0_OR_GREATER --- src/Ocelot/DependencyInjection/Features.cs | 6 ------ src/Ocelot/DependencyInjection/OcelotBuilder.cs | 2 -- src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs | 3 --- .../DiscoveryDownstreamRouteFinderTests.cs | 2 ++ .../RateLimiting/RateLimitingMiddlewareTests.cs | 4 +--- 5 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/Ocelot/DependencyInjection/Features.cs b/src/Ocelot/DependencyInjection/Features.cs index beb222605..5d1689512 100644 --- a/src/Ocelot/DependencyInjection/Features.cs +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -11,9 +11,6 @@ using Ocelot.Logging; using Ocelot.RateLimiting; -using System.Threading.RateLimiting; -using Microsoft.AspNetCore.RateLimiting; - namespace Ocelot.DependencyInjection; public static class Features @@ -37,13 +34,11 @@ public static IServiceCollection AddConfigurationValidators(this IServiceCollect /// Read The Docs: Rate Limiting. /// /// The services collection to add the feature to. - /// Root configuration object. /// The same object. public static IServiceCollection AddOcelotRateLimiting(this IServiceCollection services) => services .AddSingleton() .AddSingleton(); -#if NET7_0_OR_GREATER /// /// Ocelot feature: AspNet Rate Limiting. /// @@ -69,7 +64,6 @@ public static IServiceCollection AddAspNetRateLimiting(this IServiceCollection s return services; } -#endif /// /// Ocelot feature: Request Caching. diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index c4cc31d40..86aa16b06 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -137,9 +137,7 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.AddOcelotMessageInvokerPool(); Services.AddOcelotMetadata(); Services.AddOcelotRateLimiting(); -#if NET7_0_OR_GREATER Services.AddAspNetRateLimiting(configurationRoot); // Feature: AspNet Rate Limiting -#endif // Add ASP.NET services var assembly = typeof(FileConfigurationController).GetTypeInfo().Assembly; diff --git a/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs b/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs index 608ac4e77..6e9f1219a 100644 --- a/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs +++ b/src/Ocelot/RateLimiting/RateLimitingMiddlewareExtensions.cs @@ -11,13 +11,10 @@ public static IApplicationBuilder UseRateLimiting(this IApplicationBuilder build builder.UseMiddleware(); //use AspNet rate limiter -#if NET7_0_OR_GREATER builder.UseWhen(UseAspNetRateLimiter, rateLimitedApp => { rateLimitedApp.UseRateLimiter(); }); -#endif - return builder; } 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 de69f39b6..6d0924ea9 100644 --- a/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs @@ -1,7 +1,5 @@ -#if NET7_0_OR_GREATER +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; -#endif -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; using Ocelot.Configuration; using Ocelot.Configuration.Builder;