diff --git a/global.json b/global.json index ff8d898e..616b2c4e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@  { "projects": [ "src", "test" ], "sdk": { - "version": "1.0.0-preview2-003133" + "version": "1.0.0-preview2-003131" } } diff --git a/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs b/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs index ae8cc047..c5eababe 100644 --- a/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs +++ b/src/Ocelot/Configuration/Creator/FileOcelotConfigurationCreator.cs @@ -122,7 +122,7 @@ namespace Ocelot.Configuration.Creator Limit = fileReRoute.RateLimitOptions.Limit, Period = fileReRoute.RateLimitOptions.Period, PeriodTimespan = TimeSpan.FromSeconds(fileReRoute.RateLimitOptions.PeriodTimespan) - }); + }, globalConfiguration.RateLimitOptions.HttpStatusCode); } var serviceProviderPort = globalConfiguration?.ServiceDiscoveryProvider?.Port ?? 0; diff --git a/src/Ocelot/Configuration/File/FileRateLimitOptions.cs b/src/Ocelot/Configuration/File/FileRateLimitOptions.cs index e685ee56..655b2442 100644 --- a/src/Ocelot/Configuration/File/FileRateLimitOptions.cs +++ b/src/Ocelot/Configuration/File/FileRateLimitOptions.cs @@ -7,7 +7,6 @@ namespace Ocelot.Configuration.File { public class FileRateLimitOptions { - /// /// Gets or sets the HTTP header that holds the client identifier, by default is X-ClientId /// @@ -29,6 +28,11 @@ namespace Ocelot.Configuration.File /// Disables X-Rate-Limit and Rety-After headers /// public bool DisableRateLimitHeaders { get; set; } + + /// + /// Gets or sets the HTTP Status code returned when rate limiting occurs, by default value is set to 429 (Too Many Requests) + /// + public int HttpStatusCode { get; private set; } = 429; } diff --git a/src/Ocelot/Configuration/RateLimitOptions.cs b/src/Ocelot/Configuration/RateLimitOptions.cs index 7d4d0f4a..d5c68d23 100644 --- a/src/Ocelot/Configuration/RateLimitOptions.cs +++ b/src/Ocelot/Configuration/RateLimitOptions.cs @@ -11,7 +11,7 @@ namespace Ocelot.Configuration public class RateLimitOptions { public RateLimitOptions(bool enbleRateLimiting, string clientIdHeader, List clientWhitelist,bool disableRateLimitHeaders, - string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule) + string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode) { EnableRateLimiting = enbleRateLimiting; ClientIdHeader = clientIdHeader; @@ -20,6 +20,7 @@ namespace Ocelot.Configuration QuotaExceededMessage = quotaExceededMessage; RateLimitCounterPrefix = rateLimitCounterPrefix; RateLimitRule = rateLimitRule; + HttpStatusCode = httpStatusCode; } public RateLimitRule RateLimitRule { get; private set; } @@ -29,12 +30,12 @@ namespace Ocelot.Configuration /// /// Gets or sets the HTTP header that holds the client identifier, by default is X-ClientId /// - public string ClientIdHeader { get; private set; } = "ClientId"; + public string ClientIdHeader { get; private set; } /// /// Gets or sets the HTTP Status code returned when rate limiting occurs, by default value is set to 429 (Too Many Requests) /// - public int HttpStatusCode { get; private set; } = 429; + public int HttpStatusCode { get; private set; } /// /// Gets or sets a value that will be used as a formatter for the QuotaExceeded response message. @@ -46,7 +47,7 @@ namespace Ocelot.Configuration /// /// Gets or sets the counter prefix, used to compose the rate limit counter cache key /// - public string RateLimitCounterPrefix { get; private set; } = "ocelot"; + public string RateLimitCounterPrefix { get; private set; } /// /// Enables endpoint rate limiting based URL path and HTTP verb diff --git a/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs b/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs new file mode 100644 index 00000000..1db8f334 --- /dev/null +++ b/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Caching.Distributed; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ocelot.RateLimit +{ + public class DistributedCacheRateLimitCounterHanlder : IRateLimitCounterHandler + { + private readonly IDistributedCache _memoryCache; + + public DistributedCacheRateLimitCounterHanlder(IDistributedCache memoryCache) + { + _memoryCache = memoryCache; + } + + public void Set(string id, RateLimitCounter counter, TimeSpan expirationTime) + { + _memoryCache.SetString(id, JsonConvert.SerializeObject(counter), new DistributedCacheEntryOptions().SetAbsoluteExpiration(expirationTime)); + } + + public bool Exists(string id) + { + var stored = _memoryCache.GetString(id); + return !string.IsNullOrEmpty(stored); + } + + public RateLimitCounter? Get(string id) + { + var stored = _memoryCache.GetString(id); + if (!string.IsNullOrEmpty(stored)) + { + return JsonConvert.DeserializeObject(stored); + } + return null; + } + + public void Remove(string id) + { + _memoryCache.Remove(id); + } + } +} diff --git a/test/Ocelot.AcceptanceTests/ClientRateLimitTests.cs b/test/Ocelot.AcceptanceTests/ClientRateLimitTests.cs new file mode 100644 index 00000000..2e8c3c90 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/ClientRateLimitTests.cs @@ -0,0 +1,208 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration.File; +using Shouldly; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using TestStack.BDDfy; +using Xunit; + +namespace Ocelot.AcceptanceTests +{ + public class ClientRateLimitTests : IDisposable + { + private IWebHost _builder; + private readonly Steps _steps; + private int _counterOne; + + + public ClientRateLimitTests() + { + _steps = new Steps(); + } + + + public void Dispose() + { + _builder?.Dispose(); + _steps.Dispose(); + } + + [Fact] + public void should_call_withratelimiting() + { + var configuration = new FileConfiguration + { + ReRoutes = new List + { + new FileReRoute + { + DownstreamPathTemplate = "/api/ClientRateLimit", + DownstreamPort = 51879, + DownstreamScheme = "http", + DownstreamHost = "localhost", + UpstreamTemplate = "/api/ClientRateLimit", + UpstreamHttpMethod = "Get", + RequestIdKey = _steps.RequestIdKey, + + RateLimitOptions = new FileRateLimitRule() + { + EnableRateLimiting = true, + ClientWhitelist = new List(), + Limit = 3, + Period = "1s", + PeriodTimespan = 100 + } + } + }, + GlobalConfiguration = new FileGlobalConfiguration() + { + RateLimitOptions = new FileRateLimitOptions() + { + ClientIdHeader = "ClientId", + DisableRateLimitHeaders = false, + QuotaExceededMessage = "", + RateLimitCounterPrefix = "" + }, + RequestIdKey ="oceclientrequest" + } + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn("http://localhost:51879/api/ClientRateLimit")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 5)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(429)) + .BDDfy(); + } + + + [Fact] + public void should_call_middleware_withWhitelistClient() + { + var configuration = new FileConfiguration + { + ReRoutes = new List + { + new FileReRoute + { + DownstreamPathTemplate = "/api/ClientRateLimit", + DownstreamPort = 51879, + DownstreamScheme = "http", + DownstreamHost = "localhost", + UpstreamTemplate = "/api/ClientRateLimit", + UpstreamHttpMethod = "Get", + RequestIdKey = _steps.RequestIdKey, + + RateLimitOptions = new FileRateLimitRule() + { + EnableRateLimiting = true, + ClientWhitelist = new List() { "ocelotclient1"}, + Limit = 3, + Period = "1s", + PeriodTimespan = 100 + } + } + }, + GlobalConfiguration = new FileGlobalConfiguration() + { + RateLimitOptions = new FileRateLimitOptions() + { + ClientIdHeader = "ClientId", + DisableRateLimitHeaders = false, + QuotaExceededMessage = "", + RateLimitCounterPrefix = "" + }, + RequestIdKey = "oceclientrequest" + } + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn("http://localhost:51879/api/ClientRateLimit")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 4)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(200)) + .BDDfy(); + } + + + private void GivenThereIsAServiceRunningOn(string url) + { + _builder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .Configure(app => + { + app.Run(context => + { + _counterOne++; + context.Response.StatusCode = 200; + context.Response.WriteAsync(_counterOne.ToString()); + return Task.CompletedTask; + }); + }) + .Build(); + + _builder.Start(); + } + + //private void GetApiRateLimait(string url) + //{ + // var clientId = "ocelotclient1"; + // var request = new HttpRequestMessage(new HttpMethod("GET"), url); + // request.Headers.Add("ClientId", clientId); + + // var response = _client.SendAsync(request); + // responseStatusCode = (int)response.Result.StatusCode; + // } + + //} + + //public void WhenIGetUrlOnTheApiGatewayMultipleTimes(string url, int times) + //{ + // var clientId = "ocelotclient1"; + // var tasks = new Task[times]; + + // for (int i = 0; i < times; i++) + // { + // var urlCopy = url; + // tasks[i] = GetForServiceDiscoveryTest(urlCopy); + // Thread.Sleep(_random.Next(40, 60)); + // } + + // Task.WaitAll(tasks); + //} + + //private void WhenICallTheMiddlewareWithWhiteClient() + //{ + // var clientId = "ocelotclient2"; + // // Act + // for (int i = 0; i < 2; i++) + // { + // var request = new HttpRequestMessage(new HttpMethod("GET"), apiRateLimitPath); + // request.Headers.Add("ClientId", clientId); + + // var response = _client.SendAsync(request); + // responseStatusCode = (int)response.Result.StatusCode; + // } + //} + + //private void ThenresponseStatusCodeIs429() + //{ + // responseStatusCode.ShouldBe(429); + //} + + //private void ThenresponseStatusCodeIs200() + //{ + // responseStatusCode.ShouldBe(200); + //} + } +} \ No newline at end of file diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs index 92ab6daf..5981c343 100644 --- a/test/Ocelot.AcceptanceTests/Steps.cs +++ b/test/Ocelot.AcceptanceTests/Steps.cs @@ -184,6 +184,17 @@ namespace Ocelot.AcceptanceTests count.ShouldBeGreaterThan(0); } + public void WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit(string url, int times) + { + for (int i = 0; i < times; i++) + { + var clientId = "ocelotclient1"; + var request = new HttpRequestMessage(new HttpMethod("GET"), url); + request.Headers.Add("ClientId", clientId); + _response = _ocelotClient.SendAsync(request).Result; + } + } + public void WhenIGetUrlOnTheApiGateway(string url, string requestId) { _ocelotClient.DefaultRequestHeaders.TryAddWithoutValidation(RequestIdKey, requestId); @@ -211,6 +222,13 @@ namespace Ocelot.AcceptanceTests _response.StatusCode.ShouldBe(expectedHttpStatusCode); } + + public void ThenTheStatusCodeShouldBe(int expectedHttpStatusCode) + { + var responseStatusCode = (int)_response.StatusCode; + responseStatusCode.ShouldBe(expectedHttpStatusCode); + } + public void Dispose() { _ocelotClient?.Dispose(); diff --git a/test/Ocelot.AcceptanceTests/configuration.json b/test/Ocelot.AcceptanceTests/configuration.json index 8626f7c1..af289aab 100755 --- a/test/Ocelot.AcceptanceTests/configuration.json +++ b/test/Ocelot.AcceptanceTests/configuration.json @@ -1 +1 @@ -{"ReRoutes":[{"DownstreamPathTemplate":"41879/","UpstreamTemplate":"/","UpstreamHttpMethod":"Get","AuthenticationOptions":{"Provider":null,"ProviderRootUrl":null,"ScopeName":null,"RequireHttps":false,"AdditionalScopes":[],"ScopeSecret":null},"AddHeadersToRequest":{},"AddClaimsToRequest":{},"RouteClaimsRequirement":{},"AddQueriesToRequest":{},"RequestIdKey":null,"FileCacheOptions":{"TtlSeconds":0},"ReRouteIsCaseSensitive":false,"ServiceName":null,"DownstreamScheme":"http","DownstreamHost":"localhost","DownstreamPort":41879,"QoSOptions":{"ExceptionsAllowedBeforeBreaking":0,"DurationOfBreak":0,"TimeoutValue":0},"LoadBalancer":null,"RateLimitOptions":{"ClientWhitelist":[],"EnableRateLimiting":false,"Period":null,"PeriodTimespan":0,"Limit":0}}],"GlobalConfiguration":{"RequestIdKey":null,"ServiceDiscoveryProvider":{"Provider":null,"Host":null,"Port":0},"RateLimitOptions":{"ClientIdHeader":"ClientId","QuotaExceededMessage":null,"RateLimitCounterPrefix":"ocelot","DisableRateLimitHeaders":false}}} \ No newline at end of file +{"ReRoutes":[{"DownstreamPathTemplate":"41879/","UpstreamTemplate":"/","UpstreamHttpMethod":"Get","AuthenticationOptions":{"Provider":null,"ProviderRootUrl":null,"ScopeName":null,"RequireHttps":false,"AdditionalScopes":[],"ScopeSecret":null},"AddHeadersToRequest":{},"AddClaimsToRequest":{},"RouteClaimsRequirement":{},"AddQueriesToRequest":{},"RequestIdKey":null,"FileCacheOptions":{"TtlSeconds":0},"ReRouteIsCaseSensitive":false,"ServiceName":null,"DownstreamScheme":"http","DownstreamHost":"localhost","DownstreamPort":41879,"QoSOptions":{"ExceptionsAllowedBeforeBreaking":0,"DurationOfBreak":0,"TimeoutValue":0},"LoadBalancer":null,"RateLimitOptions":{"ClientWhitelist":[],"EnableRateLimiting":false,"Period":null,"PeriodTimespan":0,"Limit":0}}],"GlobalConfiguration":{"RequestIdKey":null,"ServiceDiscoveryProvider":{"Provider":null,"Host":null,"Port":0},"RateLimitOptions":{"ClientIdHeader":"ClientId","QuotaExceededMessage":null,"RateLimitCounterPrefix":"ocelot","DisableRateLimitHeaders":false,"HttpStatusCode":429}}} \ No newline at end of file diff --git a/test/Ocelot.UnitTests/RateLimit/ClientRateLimitMiddlewareTests.cs b/test/Ocelot.UnitTests/RateLimit/ClientRateLimitMiddlewareTests.cs index dd1dc0e0..11bca113 100644 --- a/test/Ocelot.UnitTests/RateLimit/ClientRateLimitMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/RateLimit/ClientRateLimitMiddlewareTests.cs @@ -29,7 +29,6 @@ namespace Ocelot.UnitTests.RateLimit private readonly string _url; private readonly TestServer _server; private readonly HttpClient _client; - private HttpResponseMessage _result; private OkResponse _downstreamRoute; private int responseStatusCode; @@ -71,7 +70,7 @@ namespace Ocelot.UnitTests.RateLimit { var downstreamRoute = new DownstreamRoute(new List(), new ReRouteBuilder().WithEnableRateLimiting(true).WithRateLimitOptions( - new Ocelot.Configuration.RateLimitOptions(true, "ClientId", new List(), false, "", "", new Ocelot.Configuration.RateLimitRule() { Limit = 3, Period = "1s", PeriodTimespan = TimeSpan.FromSeconds(100) })) + new Ocelot.Configuration.RateLimitOptions(true, "ClientId", new List(), false, "", "", new Ocelot.Configuration.RateLimitRule() { Limit = 3, Period = "1s", PeriodTimespan = TimeSpan.FromSeconds(100) },429)) .Build()); this.Given(x => x.GivenTheDownStreamRouteIs(downstreamRoute)) @@ -85,7 +84,7 @@ namespace Ocelot.UnitTests.RateLimit { var downstreamRoute = new DownstreamRoute(new List(), new ReRouteBuilder().WithEnableRateLimiting(true).WithRateLimitOptions( - new Ocelot.Configuration.RateLimitOptions(true, "ClientId", new List() { "ocelotclient2" }, false, "", "", new Ocelot.Configuration.RateLimitRule() { Limit = 3, Period = "1s", PeriodTimespan = TimeSpan.FromSeconds(100) })) + new Ocelot.Configuration.RateLimitOptions(true, "ClientId", new List() { "ocelotclient2" }, false, "", "", new Ocelot.Configuration.RateLimitRule() { Limit = 3, Period = "1s", PeriodTimespan = TimeSpan.FromSeconds(100) },429)) .Build()); this.Given(x => x.GivenTheDownStreamRouteIs(downstreamRoute))