From 6b1f33269cbd85f38ab26028aa6f2e70c49d8d45 Mon Sep 17 00:00:00 2001 From: Tanmay Seth Date: Fri, 8 Dec 2023 05:49:55 -0800 Subject: [PATCH 1/9] #649 Acceptance tests (#1846) * Changes related to test case of issue 649 * #649 test cases --------- Co-authored-by: Raman Maksimchuk --- test/Ocelot.AcceptanceTests/RoutingTests.cs | 58 ++++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/test/Ocelot.AcceptanceTests/RoutingTests.cs b/test/Ocelot.AcceptanceTests/RoutingTests.cs index 243d973d8..5dda3d8d4 100644 --- a/test/Ocelot.AcceptanceTests/RoutingTests.cs +++ b/test/Ocelot.AcceptanceTests/RoutingTests.cs @@ -445,10 +445,13 @@ public void should_return_response_200_when_host_has_trailing_slash() .BDDfy(); } - [Fact] - public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_template_does_not() + [Theory] + [InlineData("/products")] + [InlineData("/products/")] + public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_template_does_not(string url) { var port = PortFinder.GetRandomPort(); + var downstreamBasePath = "/products"; var configuration = new FileConfiguration { @@ -456,7 +459,7 @@ public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_templ { new() { - DownstreamPathTemplate = "/products", + DownstreamPathTemplate = downstreamBasePath, DownstreamScheme = "http", DownstreamHostAndPorts = new List { @@ -466,21 +469,60 @@ public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_templ Port = port, }, }, - UpstreamPathTemplate = "/products/", + UpstreamPathTemplate = downstreamBasePath+"/", UpstreamHttpMethod = new List { "Get" }, }, }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamBasePath, 200, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(url)) .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } - + + [Theory] + [InlineData("/account/authenticate")] + [InlineData("/account/authenticate/")] + [Trait("Issue", "649")] + public void should_fix_issue_649(string url) + { + var port = PortFinder.GetRandomPort(); + var baseUrl = $"http://localhost:{port}"; + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + UpstreamPathTemplate = "/account/authenticate/", + + DownstreamPathTemplate = "/authenticate", + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = new() + { + new("localhost", port), + }, + }, + }, + GlobalConfiguration = + { + BaseUrl = baseUrl, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn(baseUrl, "/authenticate", 200, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(url)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + [Fact] public void should_return_not_found_when_upstream_url_ends_with_forward_slash_but_template_does_not() { @@ -1064,7 +1106,7 @@ public void should_fix_issue_271() .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); - } + } private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody) { From a17242d0a9882d9bf23c31a0851917c6229ca60f Mon Sep 17 00:00:00 2001 From: Guillaume Gnaegi <58469901+ggnaegi@users.noreply.github.com> Date: Wed, 13 Dec 2023 20:42:50 +0100 Subject: [PATCH 2/9] #827 #1679 Improve performance of Request Mapper (#1724) * using ArrayPool instead of memorystream CopyToAsync * Maybe this could be a solution. It should be checked. Adding StreamHttpContent (yarp) * little bit of cleanup here * Avoiding ToLower() in IsSupportedHeader, using StringComparer.OrdinalIgnoreCase * for smaller payloads, avoid allocating a buffer that is larger than the announced content length * adding some unit tests for stream http content tests * typo * GivenThereIsAPossiblyBrokenServiceRunningOn * Some code refactorings after code review. There are still some todos, but providing some more improvements, removing the exception handling from RequestMapper. It's handled in the middleware now, we will need to analyze this in detail. * Some code cleanup * removing some commented code bits * adding more information in InvalidOperationException * changing some methods signatures in request mapper, making them static. * code review * Update gotchas.rst * Update notsupported.rst * Update gotchas.rst Add "Maximum request body size" notes * With this fix, the system is able to handle content with size > 2 Gb * adding new benchmarks, generating the payloads on the fly, from 1KB to 1024MB * Review PayloadBenchmarks * valid JSON * should make sure the payloads directory exists * Payloads folder name should match test method name --------- Co-authored-by: Raman Maksimchuk --- docs/introduction/gotchas.rst | 42 +- docs/introduction/notsupported.rst | 4 +- src/Ocelot/Request/Mapper/IRequestMapper.cs | 14 +- src/Ocelot/Request/Mapper/RequestMapper.cs | 187 ++-- .../Request/Mapper/StreamHttpContent.cs | 113 +++ .../DownstreamRequestInitialiserMiddleware.cs | 61 +- src/Ocelot/Requester/HttpClientWrapper.cs | 8 +- test/Ocelot.AcceptanceTests/ContentTests.cs | 7 - test/Ocelot.AcceptanceTests/PollyQoSTests.cs | 2 +- .../Ocelot.Benchmarks.csproj | 2 +- test/Ocelot.Benchmarks/PayloadBenchmarks.cs | 253 +++++ test/Ocelot.Benchmarks/Program.cs | 33 +- ...streamRequestInitialiserMiddlewareTests.cs | 259 +++-- .../Request/Mapper/RequestMapperTests.cs | 918 +++++++++--------- .../Request/Mapper/StreamHttpContentTests.cs | 87 ++ 15 files changed, 1220 insertions(+), 770 deletions(-) create mode 100644 src/Ocelot/Request/Mapper/StreamHttpContent.cs create mode 100644 test/Ocelot.Benchmarks/PayloadBenchmarks.cs create mode 100644 test/Ocelot.UnitTests/Request/Mapper/StreamHttpContentTests.cs diff --git a/docs/introduction/gotchas.rst b/docs/introduction/gotchas.rst index 9a8f856ae..a532b3fd5 100644 --- a/docs/introduction/gotchas.rst +++ b/docs/introduction/gotchas.rst @@ -1,12 +1,15 @@ Gotchas ============= - + +Many errors and incidents (gotchas) are related to web server hosting scenarios. +Please review deployment and web hosting common user scenarios below depending on your web server. + IIS ------ +--- Microsoft Learn: `Host ASP.NET Core on Windows with IIS `_ -We do not recommend to deploy Ocelot app to IIS environments, but if you do, keep in mind the gotchas below. +We **do not** recommend to deploy Ocelot app to IIS environments, but if you do, keep in mind the gotchas below. * When using ASP.NET Core 2.2+ and you want to use In-Process hosting, replace ``UseIISIntegration()`` with ``UseIIS()``, otherwise you will get startup errors. @@ -23,3 +26,36 @@ Probably you will find a ready solution by Ocelot community members. Finally, we have special label |IIS| for all IIS related objects. Feel free to put this label onto issues, PRs, discussions, etc. .. |IIS| image:: https://img.shields.io/badge/-IIS-c5def5.svg + +Kestrel +------- + + Microsoft Learn: `Kestrel web server in ASP.NET Core `_ + +We **do** recommend to deploy Ocelot app to self-hosting environments, aka Kestrel vs Docker. +We try to optimize Ocelot web app for Kestrel & Docker hosting scenarios, but keep in mind the following gotchas. + +* **Upload and download large files**, proxying the content through the gateway. It is strange when you pump large (static) files using the gateway. + We believe that your client apps should have direct integration to (static) files persistent storages and services: remote & destributed file systems, CDNs, static files & blob storages, etc. + We **do not** recommend to pump large files (100Mb+ or even larger 1GB+) using gateway because of performance reasons: consuming memory and CPU, long delay times, producing network errors for downstream streaming, impact on other routes. + + | The community constanly reports issues related to `large files `_ (``application/octet-stream`` content type, :ref:`chunked-encoding`, etc.), see issues `749 `_, `1472 `_. + If you still want to pump large files through an Ocelot gateway instance, we believe our PRs (`1724 `_, `1769 `_) will help resolve the issues and stabilize large content proxying. + In case of some errors, see the next point. + +* **Maximum request body size**. ASP.NET ``HttpRequest`` behaves erroneously for application instances that do not have their Kestrel `MaxRequestBodySize `_ option configured correctly and having pumped large files of unpredictable size which exceeds the limit. + + | Please review these docs: `Maximum request body size | Configure options for the ASP.NET Core Kestrel web server `_. + + | As a quick fix, use this configuration recipe: + + .. code-block:: csharp + + builder.WebHost.ConfigureKestrel((context, serverOptions) => + { + int myVideoFileMaxSize = 1_073_741_824; // assume your file storage has max file size as 1 GB (1_073_741_824) + int totalSize = myVideoFileMaxSize + 26_258_176; // and add some extra size + serverOptions.Limits.MaxRequestBodySize = totalSize; // 1_100_000_000 thus 1 GB file should not exceed the limit + }); + + Hope it helps. diff --git a/docs/introduction/notsupported.rst b/docs/introduction/notsupported.rst index 7c7c9a5f0..58eff06a1 100644 --- a/docs/introduction/notsupported.rst +++ b/docs/introduction/notsupported.rst @@ -2,7 +2,9 @@ Not Supported ============= Ocelot does not support... - + +.. _chunked-encoding: + Chunked Encoding ---------------- diff --git a/src/Ocelot/Request/Mapper/IRequestMapper.cs b/src/Ocelot/Request/Mapper/IRequestMapper.cs index e040d0c5c..901d69ade 100644 --- a/src/Ocelot/Request/Mapper/IRequestMapper.cs +++ b/src/Ocelot/Request/Mapper/IRequestMapper.cs @@ -1,11 +1,9 @@ using Microsoft.AspNetCore.Http; using Ocelot.Configuration; -using Ocelot.Responses; - -namespace Ocelot.Request.Mapper -{ - public interface IRequestMapper - { - Task> Map(HttpRequest request, DownstreamRoute downstreamRoute); - } + +namespace Ocelot.Request.Mapper; + +public interface IRequestMapper +{ + HttpRequestMessage Map(HttpRequest request, DownstreamRoute downstreamRoute); } diff --git a/src/Ocelot/Request/Mapper/RequestMapper.cs b/src/Ocelot/Request/Mapper/RequestMapper.cs index a126b48db..8bdde02bb 100644 --- a/src/Ocelot/Request/Mapper/RequestMapper.cs +++ b/src/Ocelot/Request/Mapper/RequestMapper.cs @@ -2,109 +2,86 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Primitives; using Ocelot.Configuration; -using Ocelot.Responses; - -namespace Ocelot.Request.Mapper -{ - public class RequestMapper : IRequestMapper - { - private readonly string[] _unsupportedHeaders = { "host" }; - - public async Task> Map(HttpRequest request, DownstreamRoute downstreamRoute) - { - try - { - var requestMessage = new HttpRequestMessage - { - Content = await MapContent(request), - Method = MapMethod(request, downstreamRoute), - RequestUri = MapUri(request), - Version = downstreamRoute.DownstreamHttpVersion, - }; - - MapHeaders(request, requestMessage); - - return new OkResponse(requestMessage); - } - catch (Exception ex) - { - return new ErrorResponse(new UnmappableRequestError(ex)); - } - } - - private static async Task MapContent(HttpRequest request) - { - if (request.Body == null || (request.Body.CanSeek && request.Body.Length <= 0)) - { - return null; - } - - // Never change this to StreamContent again, I forgot it doesnt work in #464. - var content = new ByteArrayContent(await ToByteArray(request.Body)); - - if (!string.IsNullOrEmpty(request.ContentType)) - { - content.Headers - .TryAddWithoutValidation("Content-Type", new[] { request.ContentType }); - } - - AddHeaderIfExistsOnRequest("Content-Language", content, request); - AddHeaderIfExistsOnRequest("Content-Location", content, request); - AddHeaderIfExistsOnRequest("Content-Range", content, request); - AddHeaderIfExistsOnRequest("Content-MD5", content, request); - AddHeaderIfExistsOnRequest("Content-Disposition", content, request); - AddHeaderIfExistsOnRequest("Content-Encoding", content, request); - - return content; - } - - private static void AddHeaderIfExistsOnRequest(string key, HttpContent content, HttpRequest request) - { - if (request.Headers.ContainsKey(key)) - { - content.Headers - .TryAddWithoutValidation(key, request.Headers[key].ToArray()); - } - } - - private static HttpMethod MapMethod(HttpRequest request, DownstreamRoute downstreamRoute) - { - if (!string.IsNullOrEmpty(downstreamRoute?.DownstreamHttpMethod)) - { - return new HttpMethod(downstreamRoute.DownstreamHttpMethod); - } - - return new HttpMethod(request.Method); - } - - private static Uri MapUri(HttpRequest request) => new(request.GetEncodedUrl()); - - private void MapHeaders(HttpRequest request, HttpRequestMessage requestMessage) - { - foreach (var header in request.Headers) - { - if (IsSupportedHeader(header)) - { - requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); - } - } - } - - private bool IsSupportedHeader(KeyValuePair header) - { - return !_unsupportedHeaders.Contains(header.Key.ToLower()); - } - - private static async Task ToByteArray(Stream stream) - { - await using (stream) - { - using (var memStream = new MemoryStream()) - { - await stream.CopyToAsync(memStream); - return memStream.ToArray(); - } - } - } - } + +namespace Ocelot.Request.Mapper; + +public class RequestMapper : IRequestMapper +{ + private static readonly HashSet UnsupportedHeaders = new(StringComparer.OrdinalIgnoreCase) { "host" }; + private static readonly string[] ContentHeaders = { "Content-Length", "Content-Language", "Content-Location", "Content-Range", "Content-MD5", "Content-Disposition", "Content-Encoding" }; + + public HttpRequestMessage Map(HttpRequest request, DownstreamRoute downstreamRoute) + { + var requestMessage = new HttpRequestMessage + { + Content = MapContent(request), + Method = MapMethod(request, downstreamRoute), + RequestUri = MapUri(request), + Version = downstreamRoute.DownstreamHttpVersion, + }; + + MapHeaders(request, requestMessage); + + return requestMessage; + } + + private static HttpContent MapContent(HttpRequest request) + { + // TODO We should check if we really need to call HttpRequest.Body.Length + // But we assume that if CanSeek is true, the length is calculated without an important overhead + if (request.Body is null or { CanSeek: true, Length: <= 0 }) + { + return null; + } + + var content = new StreamHttpContent(request.HttpContext); + + AddContentHeaders(request, content); + + return content; + } + + private static void AddContentHeaders(HttpRequest request, HttpContent content) + { + if (!string.IsNullOrEmpty(request.ContentType)) + { + content.Headers + .TryAddWithoutValidation("Content-Type", new[] { request.ContentType }); + } + + // The performance might be improved by retrieving the matching headers from the request + // instead of calling request.Headers.TryGetValue for each used content header + var matchingHeaders = ContentHeaders.Where(header => request.Headers.ContainsKey(header)); + + foreach (var key in matchingHeaders) + { + if (!request.Headers.TryGetValue(key, out var value)) + { + continue; + } + + content.Headers.TryAddWithoutValidation(key, value.ToArray()); + } + } + + private static HttpMethod MapMethod(HttpRequest request, DownstreamRoute downstreamRoute) => + !string.IsNullOrEmpty(downstreamRoute?.DownstreamHttpMethod) ? + new HttpMethod(downstreamRoute.DownstreamHttpMethod) : new HttpMethod(request.Method); + + // TODO Review this method, request.GetEncodedUrl() could throw a NullReferenceException + private static Uri MapUri(HttpRequest request) => new(request.GetEncodedUrl()); + + private static void MapHeaders(HttpRequest request, HttpRequestMessage requestMessage) + { + foreach (var header in request.Headers) + { + if (IsSupportedHeader(header)) + { + requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + } + } + + private static bool IsSupportedHeader(KeyValuePair header) => + !UnsupportedHeaders.Contains(header.Key); } diff --git a/src/Ocelot/Request/Mapper/StreamHttpContent.cs b/src/Ocelot/Request/Mapper/StreamHttpContent.cs new file mode 100644 index 000000000..0e8294db7 --- /dev/null +++ b/src/Ocelot/Request/Mapper/StreamHttpContent.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Http; +using System.Buffers; + +namespace Ocelot.Request.Mapper; + +public class StreamHttpContent : HttpContent +{ + private const int DefaultBufferSize = 65536; + public const long UnknownLength = -1; + private readonly HttpContext _context; + + public StreamHttpContent(HttpContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context, + CancellationToken cancellationToken) + => await CopyAsync(_context.Request.Body, stream, Headers.ContentLength ?? UnknownLength, false, + cancellationToken); + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + => await CopyAsync(_context.Request.Body, stream, Headers.ContentLength ?? UnknownLength, false, + CancellationToken.None); + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + + // This is used internally by HttpContent.ReadAsStreamAsync(...) + protected override Task CreateContentReadStreamAsync() + { + // Nobody should be calling this... + throw new NotImplementedException(); + } + + private static async Task CopyAsync(Stream input, Stream output, long announcedContentLength, + bool autoFlush, CancellationToken cancellation) + { + // For smaller payloads, avoid allocating a buffer that is larger than the announced content length + var minBufferSize = announcedContentLength != UnknownLength && announcedContentLength < DefaultBufferSize + ? (int)announcedContentLength + : DefaultBufferSize; + + var buffer = ArrayPool.Shared.Rent(minBufferSize); + long contentLength = 0; + try + { + while (true) + { + // Issue a zero-byte read to the input stream to defer buffer allocation until data is available. + // Note that if the underlying stream does not supporting blocking on zero byte reads, then this will + // complete immediately and won't save any memory, but will still function correctly. + var zeroByteReadTask = input.ReadAsync(Memory.Empty, cancellation); + if (zeroByteReadTask.IsCompletedSuccessfully) + { + // Consume the ValueTask's result in case it is backed by an IValueTaskSource + _ = zeroByteReadTask.Result; + } + else + { + // Take care not to return the same buffer to the pool twice in case zeroByteReadTask throws + var bufferToReturn = buffer; + buffer = null; + ArrayPool.Shared.Return(bufferToReturn); + + await zeroByteReadTask; + + buffer = ArrayPool.Shared.Rent(minBufferSize); + } + + var read = await input.ReadAsync(buffer.AsMemory(), cancellation); + contentLength += read; + + // Normally this is enforced by the server, but it could get out of sync if something in the proxy modified the body. + if (announcedContentLength != UnknownLength && contentLength > announcedContentLength) + { + throw new InvalidOperationException($"More data ({contentLength} bytes) received than the specified Content-Length of {announcedContentLength} bytes."); + } + + // End of the source stream. + if (read == 0) + { + if (announcedContentLength == UnknownLength || contentLength == announcedContentLength) + { + return; + } + else + { + throw new InvalidOperationException($"Sent {contentLength} request content bytes, but Content-Length promised {announcedContentLength}."); + } + } + + await output.WriteAsync(buffer.AsMemory(0, read), cancellation); + if (autoFlush) + { + // HttpClient doesn't always flush outgoing data unless the buffer is full or the caller asks. + // This is a problem for streaming protocols like WebSockets and gRPC. + await output.FlushAsync(cancellation); + } + } + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } +} diff --git a/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs b/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs index a765f3997..6440e3d46 100644 --- a/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs +++ b/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs @@ -2,43 +2,46 @@ using Ocelot.Logging; using Ocelot.Middleware; using Ocelot.Request.Creator; +using Ocelot.Request.Mapper; -namespace Ocelot.Request.Middleware +namespace Ocelot.Request.Middleware; + +public class DownstreamRequestInitialiserMiddleware : OcelotMiddleware { - public class DownstreamRequestInitialiserMiddleware : OcelotMiddleware + private readonly RequestDelegate _next; + private readonly IRequestMapper _requestMapper; + private readonly IDownstreamRequestCreator _creator; + + public DownstreamRequestInitialiserMiddleware(RequestDelegate next, + IOcelotLoggerFactory loggerFactory, + IRequestMapper requestMapper, + IDownstreamRequestCreator creator) + : base(loggerFactory.CreateLogger()) { - private readonly RequestDelegate _next; - private readonly Mapper.IRequestMapper _requestMapper; - private readonly IDownstreamRequestCreator _creator; + _next = next; + _requestMapper = requestMapper; + _creator = creator; + } - public DownstreamRequestInitialiserMiddleware(RequestDelegate next, - IOcelotLoggerFactory loggerFactory, - Mapper.IRequestMapper requestMapper, - IDownstreamRequestCreator creator) - : base(loggerFactory.CreateLogger()) + public async Task Invoke(HttpContext httpContext) + { + var downstreamRoute = httpContext.Items.DownstreamRoute(); + HttpRequestMessage httpRequestMessage; + + try { - _next = next; - _requestMapper = requestMapper; - _creator = creator; + httpRequestMessage = _requestMapper.Map(httpContext.Request, downstreamRoute); } - - public async Task Invoke(HttpContext httpContext) + catch (Exception ex) { - var downstreamRoute = httpContext.Items.DownstreamRoute(); - - var httpRequestMessage = await _requestMapper.Map(httpContext.Request, downstreamRoute); - - if (httpRequestMessage.IsError) - { - httpContext.Items.UpsertErrors(httpRequestMessage.Errors); - return; - } - - var downstreamRequest = _creator.Create(httpRequestMessage.Data); + // TODO Review the error handling, we should throw an exception here and use the global error handler middleware to catch it + httpContext.Items.UpsertErrors([new UnmappableRequestError(ex)]); + return; + } - httpContext.Items.UpsertDownstreamRequest(downstreamRequest); + var downstreamRequest = _creator.Create(httpRequestMessage); + httpContext.Items.UpsertDownstreamRequest(downstreamRequest); - await _next.Invoke(httpContext); - } + await _next.Invoke(httpContext); } } diff --git a/src/Ocelot/Requester/HttpClientWrapper.cs b/src/Ocelot/Requester/HttpClientWrapper.cs index 096f6afa5..42e0b4d22 100644 --- a/src/Ocelot/Requester/HttpClientWrapper.cs +++ b/src/Ocelot/Requester/HttpClientWrapper.cs @@ -12,9 +12,13 @@ public HttpClientWrapper(HttpClient client) Client = client; } - public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + public Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken = default) { - return Client.SendAsync(request, cancellationToken); + // https://www.stevejgordon.co.uk/using-httpcompletionoption-responseheadersread-to-improve-httpclient-performance-dotnet + // When using this option, we avoid the intermediate MemoryStream buffer, instead of getting the content directly from the stream exposed on the Socket. + // This avoids unnecessary allocations which is a goal in highly optimised situations. + return Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); } } } diff --git a/test/Ocelot.AcceptanceTests/ContentTests.cs b/test/Ocelot.AcceptanceTests/ContentTests.cs index ba95dfd22..18848d719 100644 --- a/test/Ocelot.AcceptanceTests/ContentTests.cs +++ b/test/Ocelot.AcceptanceTests/ContentTests.cs @@ -91,7 +91,6 @@ public void should_add_content_type_and_content_length_headers() .And(x => _steps.GivenThePostHasContentType(contentType)) .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created)) - .And(x => ThenTheContentLengthIs(11)) .And(x => ThenTheContentTypeIsIs(contentType)) .BDDfy(); } @@ -129,7 +128,6 @@ public void should_add_default_content_type_header() .And(x => _steps.GivenThePostHasContent("postContent")) .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created)) - .And(x => ThenTheContentLengthIs(11)) .And(x => ThenTheContentTypeIsIs("text/plain; charset=utf-8")) .BDDfy(); } @@ -144,11 +142,6 @@ private void ThenTheContentLengthShouldBeZero() _contentLength.ShouldBeNull(); } - private void ThenTheContentLengthIs(int expected) - { - _contentLength.ShouldBe(expected); - } - private void ThenTheContentTypeShouldBeEmpty() { _contentType.ShouldBeNullOrEmpty(); diff --git a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs index 558ae7e3a..0e6c32333 100644 --- a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs +++ b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs @@ -142,7 +142,7 @@ public void Open_circuit_should_not_effect_different_route() .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } - + [Fact(DisplayName = "1833: " + nameof(Should_timeout_per_default_after_90_seconds))] public void Should_timeout_per_default_after_90_seconds() { diff --git a/test/Ocelot.Benchmarks/Ocelot.Benchmarks.csproj b/test/Ocelot.Benchmarks/Ocelot.Benchmarks.csproj index e2cb80062..bcaf49e12 100644 --- a/test/Ocelot.Benchmarks/Ocelot.Benchmarks.csproj +++ b/test/Ocelot.Benchmarks/Ocelot.Benchmarks.csproj @@ -21,7 +21,7 @@ - + all diff --git a/test/Ocelot.Benchmarks/PayloadBenchmarks.cs b/test/Ocelot.Benchmarks/PayloadBenchmarks.cs new file mode 100644 index 000000000..7547ad921 --- /dev/null +++ b/test/Ocelot.Benchmarks/PayloadBenchmarks.cs @@ -0,0 +1,253 @@ +using BenchmarkDotNet.Order; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; + +namespace Ocelot.Benchmarks; + +[Config(typeof(PayloadBenchmarks))] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class PayloadBenchmarks : ManualConfig +{ + private IWebHost _service; + private IWebHost _ocelot; + private HttpClient _httpClient; + + private const string BasePayload = + "{\"_id\":\"65789c1611a3b1feb49f9e65\",\"index\":0,\"guid\":\"6622d724-c17d-4939-9c68-158bf2dc5c57\",\"isActive\":false,\"balance\":\"$1,398.26\",\"picture\":\"http://placehold.it/32x32\",\"age\":33,\"eyeColor\":\"blue\",\"name\":\"WilkersonPayne\",\"gender\":\"male\",\"company\":\"NEOCENT\",\"email\":\"wilkersonpayne@neocent.com\",\"phone\":\"+1(837)588-3248\",\"address\":\"932BatchelderStreet,Campo,Texas,1310\",\"about\":\"Dolorsuntminimnullatemporlaboretempornostrudnon.Irureconsectetursintenimestadduissunttemporquisnisi.Laboreoccaecatculpaaliquaipsumreprehenderitadofficia.Sunteuutinpariaturanimofficia.CommodosintLoremametincididuntvelitesse.Nonaliquasintdoeiusmodexercitation.Suntcommododolorcupidatatculpareprehenderitfugiatexquisamet.\\r\\n\",\"registered\":\"2021-09-06T11:54:41-02:00\",\"latitude\":-45.256336,\"longitude\":164.343713,\"tags\":[\"cillum\",\"cupidatat\",\"aliquip\",\"culpa\",\"non\",\"laboris\",\"non\"],\"friends\":[{\"id\":0,\"name\":\"MistyMorton\"},{\"id\":1,\"name\":\"AraceliAcosta\"},{\"id\":2,\"name\":\"WalterDelaney\"}],\"greeting\":\"Hello,WilkersonPayne!Youhave1unreadmessages.\",\"favoriteFruit\":\"strawberry\"}"; + + public PayloadBenchmarks() + { + AddColumn(StatisticColumn.AllStatistics); + AddDiagnoser(MemoryDiagnoser.Default); + AddValidator(BaselineValidator.FailOnError); + } + + [GlobalSetup] + public void SetUp() + { + var configuration = new FileConfiguration + { + Routes = + [ + new FileRoute + { + DownstreamPathTemplate = "/", + DownstreamHostAndPorts = + [ + new FileHostAndPort("localhost", 51879), + ], + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod =["Post"], + }, + ], + }; + + GivenThereIsAServiceRunningOn("http://localhost:51879", "/", 201); + GivenThereIsAConfiguration(configuration); + GivenOcelotIsRunning("http://localhost:5000"); + + _httpClient = new HttpClient(); + } + + [Benchmark(Baseline = true)] + [ArgumentsSource(nameof(Payloads))] + public async Task Baseline(string payLoadPath, string payloadName, bool isJson) + { + using var content = new StreamContent(File.OpenRead(payLoadPath)); + content.Headers.ContentType = + new MediaTypeHeaderValue(string.Concat("application/", isJson ? "json" : "octet-stream")); + + var response = await _httpClient.PostAsync("http://localhost:5000/", content); + response.EnsureSuccessStatusCode(); + } + + /// + /// Generating the payloads for the benchmarks dynamically. + /// + /// The payloads containing path, file name and a boolean indicating if the file is a json or not. + public static IEnumerable Payloads() + { + var baseDirectory = GetBaseDirectory(); + var payloadsDirectory = Path.Combine(baseDirectory, nameof(Payloads)); + + if (!Directory.Exists(payloadsDirectory)) + { + Directory.CreateDirectory(payloadsDirectory); + } + + // Array of sizes in kilobytes for JSON files + var jsonSizes = new[] { 1, 16, 32, 64, 128, 256, 512, 2 * 1024, 8 * 1024, 15 * 1024, 30 * 1024 }; + foreach (var size in jsonSizes) + { + yield return GeneratePayload(size, payloadsDirectory, $"{size}KBPayload.json", true); + } + + // Array of sizes in megabytes for DAT files + var datSizes = new[] { 10, 100, 1024 }; + foreach (var size in datSizes) + { + yield return GeneratePayload(size, payloadsDirectory, $"{size}MBPayload.dat", false); + } + } + + private static string GetBaseDirectory() + { + var baseDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + Debug.Assert(baseDirectory != null, nameof(baseDirectory) + " != null"); + return baseDirectory; + } + + private static object[] GeneratePayload(int size, string directory, string fileName, bool isJson) + { + var filePath = Path.Combine(directory, fileName); + var generateDummy = isJson ? (Func) GenerateDummyJsonFile : GenerateDummyDatFile; + return + [ + generateDummy(size, filePath), + fileName, + isJson, + ]; + } + + /// + /// Generates a dummy payload of the given size in KB. + /// The payload is a JSON array of the given size. + /// + /// The size in KB. + /// The payload path. + /// The current payload path. + private static string GenerateDummyJsonFile(int sizeInKb, string payloadPath) + { + ArgumentNullException.ThrowIfNull(payloadPath); + + if (File.Exists(payloadPath)) + { + return payloadPath; + } + + var targetSizeInBytes = sizeInKb * 1024L; + + using var fileStream = new FileStream(payloadPath, FileMode.Create, FileAccess.Write); + using var streamWriter = new StreamWriter(fileStream); + + var byteArrayLength = Encoding.UTF8.GetBytes(BasePayload).Length; + var firstObject = true; + + streamWriter.Write("["); + while (fileStream.Length < targetSizeInBytes - byteArrayLength) + { + if (!firstObject) + { + streamWriter.Write(","); + } + else + { + firstObject = false; + } + + streamWriter.Write(BasePayload); + } + + streamWriter.Write("]"); + + return payloadPath; + } + + /// + /// Generates a dummy payload of the given size in MB. + /// Avoiding maintaining a large file in the repository. + /// + /// The file size in MB. + /// The path to the payload file. + /// The payload file path. + /// Throwing an exception if the payload path is null. + private static string GenerateDummyDatFile(int sizeInMb, string payloadPath) + { + ArgumentNullException.ThrowIfNull(payloadPath); + + if (File.Exists(payloadPath)) + { + return payloadPath; + } + + using var newFile = new FileStream(payloadPath, FileMode.CreateNew); + newFile.Seek(sizeInMb * 1024L * 1024, SeekOrigin.Begin); + newFile.WriteByte(0); + newFile.Close(); + + return payloadPath; + } + + private void GivenOcelotIsRunning(string url) + { + _ocelot = new WebHostBuilder() + .UseKestrel() + .UseUrls(url) + .UseContentRoot(Directory.GetCurrentDirectory()) + .ConfigureAppConfiguration((hostingContext, config) => + { + config + .SetBasePath(hostingContext.HostingEnvironment.ContentRootPath) + .AddJsonFile("appsettings.json", true, true) + .AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", true, true) + .AddJsonFile(ConfigurationBuilderExtensions.PrimaryConfigFile, false, false) + .AddEnvironmentVariables(); + }) + .ConfigureKestrel((_, hostingOptions) => { hostingOptions.Limits.MaxRequestBodySize = 2684354561; }) + .ConfigureServices(s => { s.AddOcelot(); }) + .ConfigureLogging((hostingContext, logging) => + { + logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging")); + }) + .Configure(app => { app.UseOcelot().Wait(); }) + .Build(); + + _ocelot.Start(); + } + + public static void GivenThereIsAConfiguration(FileConfiguration fileConfiguration) + { + var configurationPath = Path.Combine(AppContext.BaseDirectory, ConfigurationBuilderExtensions.PrimaryConfigFile); + var jsonConfiguration = JsonConvert.SerializeObject(fileConfiguration); + + if (File.Exists(configurationPath)) + { + File.Delete(configurationPath); + } + + File.WriteAllText(configurationPath, jsonConfiguration); + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode) + { + _service = new WebHostBuilder() + .UseUrls(baseUrl) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .ConfigureKestrel((_, hostingOptions) => { hostingOptions.Limits.MaxRequestBodySize = 2684354561; }) + .Configure(app => + { + app.UsePathBase(basePath); + app.Run(async context => + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(string.Empty); + }); + }) + .Build(); + + _service.Start(); + } +} diff --git a/test/Ocelot.Benchmarks/Program.cs b/test/Ocelot.Benchmarks/Program.cs index 4d750e0a5..090e60aaa 100644 --- a/test/Ocelot.Benchmarks/Program.cs +++ b/test/Ocelot.Benchmarks/Program.cs @@ -1,22 +1,23 @@ using BenchmarkDotNet.Running; -namespace Ocelot.Benchmarks +namespace Ocelot.Benchmarks; + +public class Program { - public class Program + public static void Main(string[] args) { - public static void Main(string[] args) - { - var switcher = new BenchmarkSwitcher(new[] { - typeof(DictionaryBenchmarks), - typeof(UrlPathToUrlPathTemplateMatcherBenchmarks), - typeof(AllTheThingsBenchmarks), - typeof(ExceptionHandlerMiddlewareBenchmarks), - typeof(DownstreamRouteFinderMiddlewareBenchmarks), - typeof(SerilogBenchmarks), - typeof(MsLoggerBenchmarks), - }); - - switcher.Run(args); - } + var switcher = new BenchmarkSwitcher( + new[] + { + typeof(DictionaryBenchmarks), + typeof(UrlPathToUrlPathTemplateMatcherBenchmarks), + typeof(AllTheThingsBenchmarks), + typeof(ExceptionHandlerMiddlewareBenchmarks), + typeof(DownstreamRouteFinderMiddlewareBenchmarks), + typeof(SerilogBenchmarks), + typeof(MsLoggerBenchmarks), + typeof(PayloadBenchmarks), + }); + switcher.Run(args); } } diff --git a/test/Ocelot.UnitTests/Request/DownstreamRequestInitialiserMiddlewareTests.cs b/test/Ocelot.UnitTests/Request/DownstreamRequestInitialiserMiddlewareTests.cs index bca325155..854b70e6d 100644 --- a/test/Ocelot.UnitTests/Request/DownstreamRequestInitialiserMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/Request/DownstreamRequestInitialiserMiddlewareTests.cs @@ -7,139 +7,136 @@ using Ocelot.Request.Creator; using Ocelot.Request.Mapper; using Ocelot.Request.Middleware; -using Ocelot.Responses; -namespace Ocelot.UnitTests.Request +namespace Ocelot.UnitTests.Request; + +public class DownstreamRequestInitialiserMiddlewareTests { - public class DownstreamRequestInitialiserMiddlewareTests + private readonly DownstreamRequestInitialiserMiddleware _middleware; + private readonly HttpContext _httpContext; + private readonly Mock _next; + private readonly Mock _requestMapper; + private HttpRequestMessage _mappedRequest; + private readonly Exception _testException; + + public DownstreamRequestInitialiserMiddlewareTests() + { + _httpContext = new DefaultHttpContext(); + _requestMapper = new Mock(); + _next = new Mock(); + var logger = new Mock(); + _testException = new Exception("test exception"); + + var loggerFactory = new Mock(); + loggerFactory + .Setup(lf => lf.CreateLogger()) + .Returns(logger.Object); + + _middleware = new DownstreamRequestInitialiserMiddleware( + _next.Object, + loggerFactory.Object, + _requestMapper.Object, + new DownstreamRequestCreator(new FrameworkDescription())); + } + + [Fact] + public void Should_handle_valid_httpRequest() + { + this.Given(_ => GivenTheHttpContextContainsARequest()) + .And(_ => GivenTheMapperWillReturnAMappedRequest()) + .When(_ => WhenTheMiddlewareIsInvoked()) + .Then(_ => ThenTheContexRequestIsMappedToADownstreamRequest()) + .And(_ => ThenTheDownstreamRequestIsStored()) + .And(_ => ThenTheNextMiddlewareIsInvoked()) + .And(_ => ThenTheDownstreamRequestMethodIs("GET")) + .BDDfy(); + } + + [Fact] + public void Should_map_downstream_route_method_to_downstream_request() + { + this.Given(_ => GivenTheHttpContextContainsARequest()) + .And(_ => GivenTheMapperWillReturnAMappedRequest()) + .When(_ => WhenTheMiddlewareIsInvoked()) + .Then(_ => ThenTheContexRequestIsMappedToADownstreamRequest()) + .And(_ => ThenTheDownstreamRequestIsStored()) + .And(_ => ThenTheNextMiddlewareIsInvoked()) + .And(_ => ThenTheDownstreamRequestMethodIs("GET")) + .BDDfy(); + } + + [Fact] + public void Should_handle_mapping_failure() + { + this.Given(_ => GivenTheHttpContextContainsARequest()) + .And(_ => GivenTheMapperWillReturnAnError()) + .When(_ => WhenTheMiddlewareIsInvoked()) + .And(_ => ThenTheDownstreamRequestIsNotStored()) + .And(_ => ThenAPipelineErrorIsStored()) + .And(_ => ThenTheNextMiddlewareIsNotInvoked()) + .BDDfy(); + } + + private void ThenTheDownstreamRequestMethodIs(string expected) + { + _httpContext.Items.DownstreamRequest().Method.ShouldBe(expected); + } + + private void GivenTheHttpContextContainsARequest() + { + _httpContext.Items.UpsertDownstreamRoute(new DownstreamRouteBuilder().Build()); + } + + private void GivenTheMapperWillReturnAMappedRequest() + { + _mappedRequest = new HttpRequestMessage(HttpMethod.Get, "http://www.bbc.co.uk"); + + _requestMapper + .Setup(rm => rm.Map(It.IsAny(), It.IsAny())) + .Returns(_mappedRequest); + } + + private void GivenTheMapperWillReturnAnError() + { + _requestMapper + .Setup(rm => rm.Map(It.IsAny(), It.IsAny())) + .Throws(_testException); + } + + private void WhenTheMiddlewareIsInvoked() + { + _middleware.Invoke(_httpContext).GetAwaiter().GetResult(); + } + + private void ThenTheContexRequestIsMappedToADownstreamRequest() + { + _requestMapper.Verify(rm => rm.Map(_httpContext.Request, _httpContext.Items.DownstreamRoute()), Times.Once); + } + + private void ThenTheDownstreamRequestIsStored() + { + _httpContext.Items.DownstreamRequest().ShouldNotBeNull(); + } + + private void ThenTheDownstreamRequestIsNotStored() + { + _httpContext.Items.DownstreamRequest().ShouldBeNull(); + } + + private void ThenAPipelineErrorIsStored() + { + _httpContext.Items.Errors().Count.ShouldBe(1); + _httpContext.Items.Errors().First().ShouldBeOfType(); + _httpContext.Items.Errors().First().Message.ShouldBe($"Error when parsing incoming request, exception: {_testException}"); + } + + private void ThenTheNextMiddlewareIsInvoked() + { + _next.Verify(n => n(_httpContext), Times.Once); + } + + private void ThenTheNextMiddlewareIsNotInvoked() { - private readonly DownstreamRequestInitialiserMiddleware _middleware; - private readonly HttpContext _httpContext; - private readonly Mock _next; - private readonly Mock _requestMapper; - private readonly Mock _loggerFactory; - private readonly Mock _logger; - private Response _mappedRequest; - - public DownstreamRequestInitialiserMiddlewareTests() - { - _httpContext = new DefaultHttpContext(); - _requestMapper = new Mock(); - _next = new Mock(); - _logger = new Mock(); - - _loggerFactory = new Mock(); - _loggerFactory - .Setup(lf => lf.CreateLogger()) - .Returns(_logger.Object); - - _middleware = new DownstreamRequestInitialiserMiddleware( - _next.Object, - _loggerFactory.Object, - _requestMapper.Object, - new DownstreamRequestCreator(new FrameworkDescription())); - } - - [Fact] - public void Should_handle_valid_httpRequest() - { - this.Given(_ => GivenTheHttpContextContainsARequest()) - .And(_ => GivenTheMapperWillReturnAMappedRequest()) - .When(_ => WhenTheMiddlewareIsInvoked()) - .Then(_ => ThenTheContexRequestIsMappedToADownstreamRequest()) - .And(_ => ThenTheDownstreamRequestIsStored()) - .And(_ => ThenTheNextMiddlewareIsInvoked()) - .And(_ => ThenTheDownstreamRequestMethodIs("GET")) - .BDDfy(); - } - - [Fact] - public void Should_map_downstream_route_method_to_downstream_request() - { - this.Given(_ => GivenTheHttpContextContainsARequest()) - .And(_ => GivenTheMapperWillReturnAMappedRequest()) - .When(_ => WhenTheMiddlewareIsInvoked()) - .Then(_ => ThenTheContexRequestIsMappedToADownstreamRequest()) - .And(_ => ThenTheDownstreamRequestIsStored()) - .And(_ => ThenTheNextMiddlewareIsInvoked()) - .And(_ => ThenTheDownstreamRequestMethodIs("GET")) - .BDDfy(); - } - - [Fact] - public void Should_handle_mapping_failure() - { - this.Given(_ => GivenTheHttpContextContainsARequest()) - .And(_ => GivenTheMapperWillReturnAnError()) - .When(_ => WhenTheMiddlewareIsInvoked()) - .And(_ => ThenTheDownstreamRequestIsNotStored()) - .And(_ => ThenAPipelineErrorIsStored()) - .And(_ => ThenTheNextMiddlewareIsNotInvoked()) - .BDDfy(); - } - - private void ThenTheDownstreamRequestMethodIs(string expected) - { - _httpContext.Items.DownstreamRequest().Method.ShouldBe(expected); - } - - private void GivenTheHttpContextContainsARequest() - { - _httpContext.Items.UpsertDownstreamRoute(new DownstreamRouteBuilder().Build()); - } - - private void GivenTheMapperWillReturnAMappedRequest() - { - _mappedRequest = new OkResponse(new HttpRequestMessage(HttpMethod.Get, "http://www.bbc.co.uk")); - - _requestMapper - .Setup(rm => rm.Map(It.IsAny(), It.IsAny())) - .ReturnsAsync(_mappedRequest); - } - - private void GivenTheMapperWillReturnAnError() - { - _mappedRequest = new ErrorResponse(new UnmappableRequestError(new System.Exception("boooom!"))); - - _requestMapper - .Setup(rm => rm.Map(It.IsAny(), It.IsAny())) - .ReturnsAsync(_mappedRequest); - } - - private void WhenTheMiddlewareIsInvoked() - { - _middleware.Invoke(_httpContext).GetAwaiter().GetResult(); - } - - private void ThenTheContexRequestIsMappedToADownstreamRequest() - { - _requestMapper.Verify(rm => rm.Map(_httpContext.Request, _httpContext.Items.DownstreamRoute()), Times.Once); - } - - private void ThenTheDownstreamRequestIsStored() - { - _httpContext.Items.DownstreamRequest().ShouldNotBeNull(); - } - - private void ThenTheDownstreamRequestIsNotStored() - { - _httpContext.Items.DownstreamRequest().ShouldBeNull(); - } - - private void ThenAPipelineErrorIsStored() - { - _httpContext.Items.Errors().Count.ShouldBeGreaterThan(0); - _httpContext.Items.Errors().ShouldBe(_mappedRequest.Errors); - } - - private void ThenTheNextMiddlewareIsInvoked() - { - _next.Verify(n => n(_httpContext), Times.Once); - } - - private void ThenTheNextMiddlewareIsNotInvoked() - { - _next.Verify(n => n(It.IsAny()), Times.Never); - } + _next.Verify(n => n(It.IsAny()), Times.Never); } } diff --git a/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs b/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs index bd6c5a736..328fee6e5 100644 --- a/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs +++ b/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs @@ -3,472 +3,458 @@ using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Request.Mapper; -using Ocelot.Responses; using System.Security.Cryptography; using System.Text; - -namespace Ocelot.UnitTests.Request.Mapper -{ - public class RequestMapperTests - { - private readonly HttpContext _httpContext; - private readonly HttpRequest _inputRequest; - - private readonly RequestMapper _requestMapper; - - private Response _mappedRequest; - - private List> _inputHeaders = null; - - private DownstreamRoute _downstreamRoute; - - public RequestMapperTests() - { - _httpContext = new DefaultHttpContext(); - _inputRequest = _httpContext.Request; - _requestMapper = new RequestMapper(); - } - - [Theory] - [InlineData("https", "my.url:123", "/abc/DEF", "?a=1&b=2", "https://my.url:123/abc/DEF?a=1&b=2")] - [InlineData("http", "blah.com", "/d ef", "?abc=123", "http://blah.com/d%20ef?abc=123")] // note! the input is encoded when building the input request - [InlineData("http", "myusername:mypassword@abc.co.uk", null, null, "http://myusername:mypassword@abc.co.uk/")] - [InlineData("http", "點看.com", null, null, "http://xn--c1yn36f.com/")] - [InlineData("http", "xn--c1yn36f.com", null, null, "http://xn--c1yn36f.com/")] - public void Should_map_valid_request_uri(string scheme, string host, string path, string queryString, string expectedUri) - { - this.Given(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasScheme(scheme)) - .And(_ => GivenTheInputRequestHasHost(host)) - .And(_ => GivenTheInputRequestHasPath(path)) - .And(_ => GivenTheInputRequestHasQueryString(queryString)) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasUri(expectedUri)) - .BDDfy(); - } - - [Theory] - [InlineData("ftp", "google.com", "/abc/DEF", "?a=1&b=2")] - public void Should_error_on_unsupported_request_uri(string scheme, string host, string path, string queryString) - { - this.Given(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasScheme(scheme)) - .And(_ => GivenTheInputRequestHasHost(host)) - .And(_ => GivenTheInputRequestHasPath(path)) - .And(_ => GivenTheInputRequestHasQueryString(queryString)) - .When(_ => WhenMapped()) - .Then(_ => ThenAnErrorIsReturned()) - .And(_ => ThenTheMappedRequestIsNull()) - .BDDfy(); - } - - [Theory] - [InlineData("GET")] - [InlineData("POST")] - [InlineData("WHATEVER")] - public void Should_map_method(string method) - { - this.Given(_ => GivenTheInputRequestHasMethod(method)) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasMethod(method)) - .BDDfy(); - } - - [Theory] - [InlineData("", "GET")] - [InlineData(null, "GET")] - [InlineData("POST", "POST")] - public void Should_use_downstream_route_method_if_set(string input, string expected) - { - this.Given(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheDownstreamRouteMethodIs(input)) - .And(_ => GivenTheInputRequestHasAValidUri()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasMethod(expected)) - .BDDfy(); - } - - [Fact] - public void Should_map_all_headers() - { - this.Given(_ => GivenTheInputRequestHasHeaders()) - .And(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasEachHeader()) - .BDDfy(); - } - - [Fact] - public void Should_handle_no_headers() - { - this.Given(_ => GivenTheInputRequestHasNoHeaders()) - .And(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasNoHeaders()) - .BDDfy(); - } - - [Fact] - public void Should_map_content() - { - this.Given(_ => GivenTheInputRequestHasContent("This is my content")) - .And(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasContent("This is my content")) - .BDDfy(); - } - - [Fact] - public void Should_handle_no_content() - { - this.Given(_ => GivenTheInputRequestHasNullContent()) - .And(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasNoContent()) - .BDDfy(); - } - - [Fact] - public void Should_handle_no_content_type() - { - this.Given(_ => GivenTheInputRequestHasNoContentType()) - .And(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasNoContent()) - .BDDfy(); - } - - [Fact] - public void Should_handle_no_content_length() - { - this.Given(_ => GivenTheInputRequestHasNoContentLength()) - .And(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasNoContent()) - .BDDfy(); - } - - [Fact] - public void Should_map_content_headers() - { - byte[] md5bytes = new byte[0]; - using (var md5 = MD5.Create()) - { - md5bytes = md5.ComputeHash(Encoding.UTF8.GetBytes("some md5")); - } - - this.Given(_ => GivenTheInputRequestHasContent("This is my content")) - .And(_ => GivenTheContentTypeIs("application/json")) - .And(_ => GivenTheContentEncodingIs("gzip, compress")) - .And(_ => GivenTheContentLanguageIs("english")) - .And(_ => GivenTheContentLocationIs("/my-receipts/38")) - .And(_ => GivenTheContentRangeIs("bytes 1-2/*")) - .And(_ => GivenTheContentDispositionIs("inline")) - .And(_ => GivenTheContentMD5Is(md5bytes)) - .And(_ => GivenTheInputRequestHasMethod("GET")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasContentTypeHeader("application/json")) - .And(_ => ThenTheMappedRequestHasContentEncodingHeader("gzip", "compress")) - .And(_ => ThenTheMappedRequestHasContentLanguageHeader("english")) - .And(_ => ThenTheMappedRequestHasContentLocationHeader("/my-receipts/38")) - .And(_ => ThenTheMappedRequestHasContentMD5Header(md5bytes)) - .And(_ => ThenTheMappedRequestHasContentRangeHeader()) - .And(_ => ThenTheMappedRequestHasContentDispositionHeader("inline")) - .And(_ => ThenTheMappedRequestHasContentSize("This is my content".Length)) - .And(_ => ThenTheContentHeadersAreNotAddedToNonContentHeaders()) - .BDDfy(); - } - - [Fact] - public void should_not_add_content_headers() - { - this.Given(_ => GivenTheInputRequestHasContent("This is my content")) - .And(_ => GivenTheContentTypeIs("application/json")) - .And(_ => GivenTheInputRequestHasMethod("POST")) - .And(_ => GivenTheInputRequestHasAValidUri()) - .And(_ => GivenTheDownstreamRoute()) - .When(_ => WhenMapped()) - .Then(_ => ThenNoErrorIsReturned()) - .And(_ => ThenTheMappedRequestHasContentTypeHeader("application/json")) - .And(_ => ThenTheMappedRequestHasContentSize("This is my content".Length)) - .And(_ => ThenTheOtherContentTypeHeadersAreNotMapped()) - .BDDfy(); - } - - private void GivenTheDownstreamRouteMethodIs(string input) - { - _downstreamRoute = new DownstreamRouteBuilder() - .WithDownStreamHttpMethod(input) - .WithDownstreamHttpVersion(new Version("1.1")).Build(); - } - - private void GivenTheDownstreamRoute() - { - _downstreamRoute = new DownstreamRouteBuilder() - .WithDownstreamHttpVersion(new Version("1.1")).Build(); - } - - private void GivenTheInputRequestHasNoContentLength() - { - _inputRequest.ContentLength = null; - } - - private void GivenTheInputRequestHasNoContentType() - { - _inputRequest.ContentType = null; - } - - private void ThenTheContentHeadersAreNotAddedToNonContentHeaders() - { - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-Disposition"); - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-ContentMD5"); - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-ContentRange"); - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-ContentLanguage"); - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-ContentEncoding"); - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-ContentLocation"); - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-Length"); - _mappedRequest.Data.Headers.ShouldNotContain(x => x.Key == "Content-Type"); - } - - private void ThenTheOtherContentTypeHeadersAreNotMapped() - { - _mappedRequest.Data.Content.Headers.ContentDisposition.ShouldBeNull(); - _mappedRequest.Data.Content.Headers.ContentMD5.ShouldBeNull(); - _mappedRequest.Data.Content.Headers.ContentRange.ShouldBeNull(); - _mappedRequest.Data.Content.Headers.ContentLanguage.ShouldBeEmpty(); - _mappedRequest.Data.Content.Headers.ContentEncoding.ShouldBeEmpty(); - _mappedRequest.Data.Content.Headers.ContentLocation.ShouldBeNull(); - } - - private void ThenTheMappedRequestHasContentDispositionHeader(string expected) - { - _mappedRequest.Data.Content.Headers.ContentDisposition.DispositionType.ShouldBe(expected); - } - - private void GivenTheContentDispositionIs(string input) - { - _inputRequest.Headers.Append("Content-Disposition", input); - } - - private void ThenTheMappedRequestHasContentMD5Header(byte[] expected) - { - _mappedRequest.Data.Content.Headers.ContentMD5.ShouldBe(expected); - } - - private void GivenTheContentMD5Is(byte[] input) - { - var base64 = Convert.ToBase64String(input); - _inputRequest.Headers.Append("Content-MD5", base64); - } - - private void ThenTheMappedRequestHasContentRangeHeader() - { - _mappedRequest.Data.Content.Headers.ContentRange.From.ShouldBe(1); - _mappedRequest.Data.Content.Headers.ContentRange.To.ShouldBe(2); - } - - private void GivenTheContentRangeIs(string input) - { - _inputRequest.Headers.Append("Content-Range", input); - } - - private void ThenTheMappedRequestHasContentLocationHeader(string expected) - { - _mappedRequest.Data.Content.Headers.ContentLocation.OriginalString.ShouldBe(expected); - } - - private void GivenTheContentLocationIs(string input) - { - _inputRequest.Headers.Append("Content-Location", input); - } - - private void ThenTheMappedRequestHasContentLanguageHeader(string expected) - { - _mappedRequest.Data.Content.Headers.ContentLanguage.First().ShouldBe(expected); - } - - private void GivenTheContentLanguageIs(string input) - { - _inputRequest.Headers.Append("Content-Language", input); - } - - private void ThenTheMappedRequestHasContentEncodingHeader(string expected, string expectedTwo) - { - _mappedRequest.Data.Content.Headers.ContentEncoding.ToArray()[0].ShouldBe(expected); - _mappedRequest.Data.Content.Headers.ContentEncoding.ToArray()[1].ShouldBe(expectedTwo); - } - - private void GivenTheContentEncodingIs(string input) - { - _inputRequest.Headers.Append("Content-Encoding", input); - } - - private void GivenTheContentTypeIs(string contentType) - { - _inputRequest.ContentType = contentType; - } - - private void ThenTheMappedRequestHasContentTypeHeader(string expected) - { - _mappedRequest.Data.Content.Headers.ContentType.MediaType.ShouldBe(expected); - } - - private void ThenTheMappedRequestHasContentSize(long expected) - { - _mappedRequest.Data.Content.Headers.ContentLength.ShouldBe(expected); - } - - private void GivenTheInputRequestHasMethod(string method) - { - _inputRequest.Method = method; - } - - private void GivenTheInputRequestHasScheme(string scheme) - { - _inputRequest.Scheme = scheme; - } - - private void GivenTheInputRequestHasHost(string host) - { - _inputRequest.Host = new HostString(host); - } - - private void GivenTheInputRequestHasPath(string path) - { - if (path != null) - { - _inputRequest.Path = path; - } - } - - private void GivenTheInputRequestHasQueryString(string querystring) - { - if (querystring != null) - { - _inputRequest.QueryString = new QueryString(querystring); - } - } - - private void GivenTheInputRequestHasAValidUri() - { - GivenTheInputRequestHasScheme("http"); - GivenTheInputRequestHasHost("www.google.com"); - } - - private void GivenTheInputRequestHasHeaders() - { - _inputHeaders = new List>() - { - new("abc", new StringValues(new string[]{"123","456" })), - new("def", new StringValues(new string[]{"789","012" })), - }; - - foreach (var inputHeader in _inputHeaders) - { - _inputRequest.Headers.Add(inputHeader); - } - } - - private void GivenTheInputRequestHasNoHeaders() - { - _inputRequest.Headers.Clear(); - } - - private void GivenTheInputRequestHasContent(string content) - { - _inputRequest.Body = new MemoryStream(Encoding.UTF8.GetBytes(content)); - } - - private void GivenTheInputRequestHasNullContent() - { - _inputRequest.Body = null; - } - - private async Task WhenMapped() - { - _mappedRequest = await _requestMapper.Map(_inputRequest, _downstreamRoute); - } - - private void ThenNoErrorIsReturned() - { - _mappedRequest.IsError.ShouldBeFalse(); - } - - private void ThenAnErrorIsReturned() - { - _mappedRequest.IsError.ShouldBeTrue(); - } - - private void ThenTheMappedRequestHasUri(string expectedUri) - { - _mappedRequest.Data.RequestUri.OriginalString.ShouldBe(expectedUri); - } - - private void ThenTheMappedRequestHasMethod(string expectedMethod) - { - _mappedRequest.Data.Method.ToString().ShouldBe(expectedMethod); - } - - private void ThenTheMappedRequestHasEachHeader() - { - _mappedRequest.Data.Headers.Count().ShouldBe(_inputHeaders.Count); - foreach (var header in _mappedRequest.Data.Headers) - { - var inputHeader = _inputHeaders.First(h => h.Key == header.Key); - inputHeader.ShouldNotBe(default(KeyValuePair)); - inputHeader.Value.Count().ShouldBe(header.Value.Count()); - foreach (var inputHeaderValue in inputHeader.Value) - { - header.Value.Any(v => v == inputHeaderValue); - } - } - } - - private void ThenTheMappedRequestHasNoHeaders() - { - _mappedRequest.Data.Headers.Count().ShouldBe(0); - } - - private void ThenTheMappedRequestHasContent(string expectedContent) - { - _mappedRequest.Data.Content.ReadAsStringAsync().GetAwaiter().GetResult().ShouldBe(expectedContent); - } - - private void ThenTheMappedRequestHasNoContent() - { - _mappedRequest.Data.Content.ShouldBeNull(); - } - - private void ThenTheMappedRequestIsNull() - { - _mappedRequest.Data.ShouldBeNull(); - } - } + +namespace Ocelot.UnitTests.Request.Mapper; + +public class RequestMapperTests +{ + private readonly HttpRequest _inputRequest; + private readonly RequestMapper _requestMapper; + private HttpRequestMessage _mappedRequest; + private List> _inputHeaders; + private DownstreamRoute _downstreamRoute; + + public RequestMapperTests() + { + HttpContext httpContext = new DefaultHttpContext(); + _inputRequest = httpContext.Request; + _requestMapper = new RequestMapper(); + } + + [Theory] + [InlineData("https", "my.url:123", "/abc/DEF", "?a=1&b=2", "https://my.url:123/abc/DEF?a=1&b=2")] + [InlineData("http", "blah.com", "/d ef", "?abc=123", + "http://blah.com/d%20ef?abc=123")] // note! the input is encoded when building the input request + [InlineData("http", "myusername:mypassword@abc.co.uk", null, null, "http://myusername:mypassword@abc.co.uk/")] + [InlineData("http", "點看.com", null, null, "http://xn--c1yn36f.com/")] + [InlineData("http", "xn--c1yn36f.com", null, null, "http://xn--c1yn36f.com/")] + public void Should_map_valid_request_uri(string scheme, string host, string path, string queryString, + string expectedUri) + { + this.Given(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasScheme(scheme)) + .And(_ => GivenTheInputRequestHasHost(host)) + .And(_ => GivenTheInputRequestHasPath(path)) + .And(_ => GivenTheInputRequestHasQueryString(queryString)) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasUri(expectedUri)) + .BDDfy(); + } + + [Theory] + [InlineData("ftp", "google.com", "/abc/DEF", "?a=1&b=2")] + public void Should_error_on_unsupported_request_uri(string scheme, string host, string path, string queryString) + { + this.Given(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasScheme(scheme)) + .And(_ => GivenTheInputRequestHasHost(host)) + .And(_ => GivenTheInputRequestHasPath(path)) + .And(_ => GivenTheInputRequestHasQueryString(queryString)) + .Then(_ => ThenMapThrowsException()) + .BDDfy(); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("WHATEVER")] + public void Should_map_method(string method) + { + this.Given(_ => GivenTheInputRequestHasMethod(method)) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasMethod(method)) + .BDDfy(); + } + + [Theory] + [InlineData("", "GET")] + [InlineData(null, "GET")] + [InlineData("POST", "POST")] + public void Should_use_downstream_route_method_if_set(string input, string expected) + { + this.Given(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheDownstreamRouteMethodIs(input)) + .And(_ => GivenTheInputRequestHasAValidUri()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasMethod(expected)) + .BDDfy(); + } + + [Fact] + public void Should_map_all_headers() + { + this.Given(_ => GivenTheInputRequestHasHeaders()) + .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasEachHeader()) + .BDDfy(); + } + + [Fact] + public void Should_handle_no_headers() + { + this.Given(_ => GivenTheInputRequestHasNoHeaders()) + .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasNoHeaders()) + .BDDfy(); + } + + [Fact] + public void Should_map_content() + { + this.Given(_ => GivenTheInputRequestHasContent("This is my content")) + .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasContent("This is my content")) + .BDDfy(); + } + + [Fact] + public void Should_handle_no_content() + { + this.Given(_ => GivenTheInputRequestHasNullContent()) + .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasNoContent()) + .BDDfy(); + } + + [Fact] + public void Should_handle_no_content_type() + { + this.Given(_ => GivenTheInputRequestHasNoContentType()) + .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasNoContent()) + .BDDfy(); + } + + [Fact] + public void Should_handle_no_content_length() + { + this.Given(_ => GivenTheInputRequestHasNoContentLength()) + .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasNoContent()) + .BDDfy(); + } + + [Fact] + public void Should_map_content_headers() + { + var md5Bytes = MD5.HashData("some md5"u8.ToArray()); + + this.Given(_ => GivenTheInputRequestHasContent("This is my content")) + .And(_ => GivenTheContentTypeIs("application/json")) + .And(_ => GivenTheContentEncodingIs("gzip, compress")) + .And(_ => GivenTheContentLanguageIs("english")) + .And(_ => GivenTheContentLocationIs("/my-receipts/38")) + .And(_ => GivenTheContentRangeIs("bytes 1-2/*")) + .And(_ => GivenTheContentDispositionIs("inline")) + .And(_ => GivenTheContentMD5Is(md5Bytes)) + .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasContentTypeHeader("application/json")) + .And(_ => ThenTheMappedRequestHasContentEncodingHeader("gzip", "compress")) + .And(_ => ThenTheMappedRequestHasContentLanguageHeader("english")) + .And(_ => ThenTheMappedRequestHasContentLocationHeader("/my-receipts/38")) + .And(_ => ThenTheMappedRequestHasContentMD5Header(md5Bytes)) + .And(_ => ThenTheMappedRequestHasContentRangeHeader()) + .And(_ => ThenTheMappedRequestHasContentDispositionHeader("inline")) + .And(_ => ThenTheContentHeadersAreNotAddedToNonContentHeaders()) + .BDDfy(); + } + + [Fact] + public void should_not_add_content_headers() + { + this.Given(_ => GivenTheInputRequestHasContent("This is my content")) + .And(_ => GivenTheContentTypeIs("application/json")) + .And(_ => GivenTheInputRequestHasMethod("POST")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasContentTypeHeader("application/json")) + .And(_ => ThenTheOtherContentTypeHeadersAreNotMapped()) + .BDDfy(); + } + + private void GivenTheDownstreamRouteMethodIs(string input) + { + _downstreamRoute = new DownstreamRouteBuilder() + .WithDownStreamHttpMethod(input) + .WithDownstreamHttpVersion(new Version("1.1")).Build(); + } + + private void GivenTheDownstreamRoute() + { + _downstreamRoute = new DownstreamRouteBuilder() + .WithDownstreamHttpVersion(new Version("1.1")).Build(); + } + + private void GivenTheInputRequestHasNoContentLength() + { + _inputRequest.ContentLength = null; + } + + private void GivenTheInputRequestHasNoContentType() + { + _inputRequest.ContentType = null; + } + + private void ThenTheContentHeadersAreNotAddedToNonContentHeaders() + { + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-Disposition"); + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-ContentMD5"); + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-ContentRange"); + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-ContentLanguage"); + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-ContentEncoding"); + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-ContentLocation"); + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-Length"); + _mappedRequest.Headers.ShouldNotContain(x => x.Key == "Content-Type"); + } + + private void ThenTheOtherContentTypeHeadersAreNotMapped() + { + Assert.NotNull(_mappedRequest.Content); + _mappedRequest.Content.Headers.ContentDisposition.ShouldBeNull(); + _mappedRequest.Content.Headers.ContentMD5.ShouldBeNull(); + _mappedRequest.Content.Headers.ContentRange.ShouldBeNull(); + _mappedRequest.Content.Headers.ContentLanguage.ShouldBeEmpty(); + _mappedRequest.Content.Headers.ContentEncoding.ShouldBeEmpty(); + _mappedRequest.Content.Headers.ContentLocation.ShouldBeNull(); + } + + private void ThenTheMappedRequestHasContentDispositionHeader(string expected) + { + Assert.NotNull(_mappedRequest.Content); + Assert.NotNull(_mappedRequest.Content.Headers.ContentDisposition); + _mappedRequest.Content.Headers.ContentDisposition.DispositionType.ShouldBe(expected); + } + + private void GivenTheContentDispositionIs(string input) + { + _inputRequest.Headers.Append("Content-Disposition", input); + } + + private void ThenTheMappedRequestHasContentMD5Header(byte[] expected) + { + Assert.NotNull(_mappedRequest.Content); + _mappedRequest.Content.Headers.ContentMD5.ShouldBe(expected); + } + + private void GivenTheContentMD5Is(byte[] input) + { + var base64 = Convert.ToBase64String(input); + _inputRequest.Headers.Append("Content-MD5", base64); + } + + private void ThenTheMappedRequestHasContentRangeHeader() + { + Assert.NotNull(_mappedRequest.Content); + Assert.NotNull(_mappedRequest.Content.Headers.ContentRange); + _mappedRequest.Content.Headers.ContentRange.From.ShouldBe(1); + _mappedRequest.Content.Headers.ContentRange.To.ShouldBe(2); + } + + private void GivenTheContentRangeIs(string input) + { + _inputRequest.Headers.Append("Content-Range", input); + } + + private void ThenTheMappedRequestHasContentLocationHeader(string expected) + { + Assert.NotNull(_mappedRequest.Content); + Assert.NotNull(_mappedRequest.Content.Headers.ContentLocation); + _mappedRequest.Content.Headers.ContentLocation.OriginalString.ShouldBe(expected); + } + + private void GivenTheContentLocationIs(string input) + { + _inputRequest.Headers.Append("Content-Location", input); + } + + private void ThenTheMappedRequestHasContentLanguageHeader(string expected) + { + Assert.NotNull(_mappedRequest.Content); + _mappedRequest.Content.Headers.ContentLanguage.First().ShouldBe(expected); + } + + private void GivenTheContentLanguageIs(string input) + { + _inputRequest.Headers.Append("Content-Language", input); + } + + private void ThenTheMappedRequestHasContentEncodingHeader(string expected, string expectedTwo) + { + Assert.NotNull(_mappedRequest.Content); + _mappedRequest.Content.Headers.ContentEncoding.ToArray()[0].ShouldBe(expected); + _mappedRequest.Content.Headers.ContentEncoding.ToArray()[1].ShouldBe(expectedTwo); + } + + private void GivenTheContentEncodingIs(string input) + { + _inputRequest.Headers.Append("Content-Encoding", input); + } + + private void GivenTheContentTypeIs(string contentType) + { + _inputRequest.ContentType = contentType; + } + + private void ThenTheMappedRequestHasContentTypeHeader(string expected) + { + Assert.NotNull(_mappedRequest.Content); + Assert.NotNull(_mappedRequest.Content.Headers.ContentType); + _mappedRequest.Content.Headers.ContentType.MediaType.ShouldBe(expected); + } + + private void ThenTheMappedRequestHasContentSize(long expected) + { + Assert.NotNull(_mappedRequest.Content); + _mappedRequest.Content.Headers.ContentLength.ShouldBe(expected); + } + + private void GivenTheInputRequestHasMethod(string method) + { + _inputRequest.Method = method; + } + + private void GivenTheInputRequestHasScheme(string scheme) + { + _inputRequest.Scheme = scheme; + } + + private void GivenTheInputRequestHasHost(string host) + { + _inputRequest.Host = new HostString(host); + } + + private void GivenTheInputRequestHasPath(string path) + { + if (path != null) + { + _inputRequest.Path = path; + } + } + + private void GivenTheInputRequestHasQueryString(string querystring) + { + if (querystring != null) + { + _inputRequest.QueryString = new QueryString(querystring); + } + } + + private void GivenTheInputRequestHasAValidUri() + { + GivenTheInputRequestHasScheme("http"); + GivenTheInputRequestHasHost("www.google.com"); + } + + private void GivenTheInputRequestHasHeaders() + { + _inputHeaders = new() + { + new("abc", new StringValues(new string[] { "123", "456" })), + new("def", new StringValues(new string[] { "789", "012" })), + }; + + foreach (var inputHeader in _inputHeaders) + { + _inputRequest.Headers.Add(inputHeader); + } + } + + private void GivenTheInputRequestHasNoHeaders() + { + _inputRequest.Headers.Clear(); + } + + private void GivenTheInputRequestHasContent(string content) + { + _inputRequest.Body = new MemoryStream(Encoding.UTF8.GetBytes(content)); + } + + private void GivenTheInputRequestHasNullContent() + { + _inputRequest.Body = null!; + } + + private void WhenMapped() + { + _mappedRequest = _requestMapper.Map(_inputRequest, _downstreamRoute); + } + + private void ThenMapThrowsException() + { + Assert.Throws(() => _requestMapper.Map(_inputRequest, _downstreamRoute)); + } + + private void ThenTheMappedRequestHasUri(string expectedUri) + { + Assert.NotNull(_mappedRequest.RequestUri); + _mappedRequest.RequestUri.OriginalString.ShouldBe(expectedUri); + } + + private void ThenTheMappedRequestHasMethod(string expectedMethod) + { + _mappedRequest.Method.ToString().ShouldBe(expectedMethod); + } + + private void ThenTheMappedRequestHasEachHeader() + { + _mappedRequest.Headers.Count().ShouldBe(_inputHeaders.Count); + foreach (var header in _mappedRequest.Headers) + { + var inputHeader = _inputHeaders.First(h => h.Key == header.Key); + inputHeader.ShouldNotBe(default); + inputHeader.Value.Count.ShouldBe(header.Value.Count()); + foreach (var inputHeaderValue in inputHeader.Value) + { + Assert.Contains(header.Value, v => v == inputHeaderValue); + } + } + } + + private void ThenTheMappedRequestHasNoHeaders() + { + _mappedRequest.Headers.Count().ShouldBe(0); + } + + private void ThenTheMappedRequestHasContent(string expectedContent) + { + Assert.NotNull(_mappedRequest.Content); + _mappedRequest.Content.ReadAsStringAsync().GetAwaiter().GetResult().ShouldBe(expectedContent); + } + + private void ThenTheMappedRequestHasNoContent() + { + _mappedRequest.Content.ShouldBeNull(); + } + + private void ThenTheMappedRequestIsNull() + { + _mappedRequest.ShouldBeNull(); + } } diff --git a/test/Ocelot.UnitTests/Request/Mapper/StreamHttpContentTests.cs b/test/Ocelot.UnitTests/Request/Mapper/StreamHttpContentTests.cs new file mode 100644 index 000000000..b8ae8a50f --- /dev/null +++ b/test/Ocelot.UnitTests/Request/Mapper/StreamHttpContentTests.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Request.Mapper; +using System.Reflection; +using System.Text; + +namespace Ocelot.UnitTests.Request.Mapper; + +public class StreamHttpContentTests +{ + private readonly HttpContext _httpContext; + + private const string PayLoad = + "[{\"_id\":\"65416ef7eafdf7953c4d7319\",\"index\":0,\"guid\":\"254b515d-0569-494d-9bc8-e21c8bd0365e\",\"isActive\":false,\"balance\":\"$1,225.59\",\"picture\":\"http://placehold.it/32x32\",\"age\":26,\"eyeColor\":\"blue\",\"name\":\"FayHatfield\",\"gender\":\"female\",\"company\":\"VIASIA\",\"email\":\"fayhatfield@viasia.com\",\"phone\":\"+1(970)416-2792\",\"address\":\"768MontroseAvenue,Mansfield,NewMexico,8890\",\"about\":\"Duisoccaecatdoloreeiusmoddoipsummollitaliquipnostrudqui.Cillumdoexercitationexercitationexcepteurincididuntadipisicingminimconsecteturofficiaanimdoloreincididuntlaborealiqua.Tempordoloreirurecillumadnullasuntoccaecatsitnulladosit.Sitnostrudullamcolaborisvelitvelitetofficiasitenimipsumaute.\\r\\n\",\"registered\":\"2023-07-03T03:10:08-02:00\",\"latitude\":0.117661,\"longitude\":-65.570177,\"tags\":[\"Lorem\",\"consequat\",\"consectetur\",\"pariatur\",\"fugiat\",\"est\",\"mollit\"],\"friends\":[{\"id\":0,\"name\":\"LynetteMelendez\"},{\"id\":1,\"name\":\"DrakeMay\"},{\"id\":2,\"name\":\"JenningsConrad\"}],\"greeting\":\"Hello,FayHatfield!Youhave3unreadmessages.\",\"favoriteFruit\":\"apple\"}]"; + + public StreamHttpContentTests() + { + _httpContext = new DefaultHttpContext(); + } + + [Fact] + public async Task Copy_body_to_stream_and_stream_content_should_match_payload() + { + var sut = StreamHttpContentFactory(); + using var stream = new MemoryStream(); + await sut.CopyToAsync(stream); + + stream.Position = 0; + var result = Encoding.UTF8.GetString(stream.ToArray()); + result.ShouldBe(PayLoad); + } + + [Fact] + public async Task Copy_body_to_stream_with_unknown_length_and_stream_content_should_match_payload() + { + var bytes = Encoding.UTF8.GetBytes(PayLoad); + using var inputStream = new MemoryStream(bytes); + using var outputStream = new MemoryStream(); + await CopyAsyncTest(new StreamHttpContent(_httpContext), + [inputStream, outputStream, StreamHttpContent.UnknownLength, false, CancellationToken.None]); + inputStream.Position = 0; + outputStream.Position = 0; + var result = Encoding.UTF8.GetString(outputStream.ToArray()); + result.ShouldBe(PayLoad); + } + + [Fact] + public async Task Copy_body_to_stream_with_body_length_and_stream_content_should_match_payload() + { + var bytes = Encoding.UTF8.GetBytes(PayLoad); + using var inputStream = new MemoryStream(bytes); + using var outputStream = new MemoryStream(); + await CopyAsyncTest(new StreamHttpContent(_httpContext), + [inputStream, outputStream, bytes.Length, false, CancellationToken.None]); + inputStream.Position = 0; + outputStream.Position = 0; + var result = Encoding.UTF8.GetString(outputStream.ToArray()); + result.ShouldBe(PayLoad); + } + + [Fact] + public async Task Should_throw_if_passed_body_length_does_not_match_real_body_length() + { + var bytes = Encoding.UTF8.GetBytes(PayLoad); + using var inputStream = new MemoryStream(bytes); + using var outputStream = new MemoryStream(); + await Assert.ThrowsAsync(async () => + await CopyAsyncTest(new StreamHttpContent(_httpContext), + [inputStream, outputStream, 10, false, CancellationToken.None])); + } + + private StreamHttpContent StreamHttpContentFactory() + { + var bytes = Encoding.UTF8.GetBytes(PayLoad); + _httpContext.Request.Body = new MemoryStream(bytes); + return new StreamHttpContent(_httpContext); + } + + private static async Task CopyAsyncTest(StreamHttpContent streamHttpContent, object[] parameters) + { + var bindingAttr = BindingFlags.NonPublic | BindingFlags.Static; + var method = typeof(StreamHttpContent).GetMethod("CopyAsync", bindingAttr) ?? + throw new Exception("Could not find CopyAsync"); + var task = (Task)method.Invoke(streamHttpContent, parameters) ?? + throw new Exception("Could not invoke CopyAsync"); + await task.ConfigureAwait(false); + } +} From ba641b245968294245d487a7c33d1bcc039f789c Mon Sep 17 00:00:00 2001 From: Raman Maksimchuk Date: Thu, 14 Dec 2023 12:18:17 +0300 Subject: [PATCH 3/9] #1172 Default CacheKeyGenerator (#1849) * Cache by header value: a new Header property in (File)CacheOptions configuration of a route (#1172) @EngRajabi, Mohsen Rajabi (7): add header to file cache option fix private set fix fix build fail fix: fix review comment. add unit test for change @raman-m, Raman Maksimchuk (1): Update caching.rst @raman-m (7): Fix errors Fix errors Fix styling warnings Refactor tests Add Delimiter Refactor generator Add unit tests * Review after cherry pick * Some refactoring * Disable request body hashing * Make CacheKeyGenerator default * Refactor property EnableContentHashing * Fix unit test. Add def cstor for CacheOptions * Remove virtual modifier * file-scoped namespace * Move to feature folder * Fix warnings * Refactor CachingTests * Add acceptance tests of #1172 * Move to feature folder * Refactor HttpClientCachingTests --------- Co-authored-by: Mohsen Rajabi --- docs/features/caching.rst | 5 +- .../OcelotBuilderExtensions.cs | 2 +- src/Ocelot/Cache/CacheKeyGenerator.cs | 20 -- src/Ocelot/Cache/DefaultCacheKeyGenerator.cs | 46 ++++ src/Ocelot/Cache/ICacheKeyGenerator.cs | 5 +- .../Cache/Middleware/OutputCacheMiddleware.cs | 21 +- src/Ocelot/Configuration/CacheOptions.cs | 38 ++- .../Configuration/Creator/RoutesCreator.cs | 2 +- .../Configuration/File/FileCacheOptions.cs | 5 +- .../DependencyInjection/OcelotBuilder.cs | 2 +- .../Request/Middleware/DownstreamRequest.cs | 10 +- .../Middleware/RequestIdMiddleware.cs | 4 +- .../Caching/CachingTests.cs | 205 ++++++++++++++++ .../Caching/HttpClientCachingTests.cs | 120 +++++++++ test/Ocelot.AcceptanceTests/CachingTests.cs | 228 ------------------ .../HttpClientCachingTests.cs | 165 ------------- .../Cache/CacheKeyGeneratorTests.cs | 32 --- .../Cache/DefaultCacheKeyGeneratorTests.cs | 134 ++++++++++ .../Cache/OutputCacheMiddlewareTests.cs | 4 +- .../OutputCacheMiddlewareRealCacheTests.cs | 6 +- 20 files changed, 566 insertions(+), 488 deletions(-) delete mode 100644 src/Ocelot/Cache/CacheKeyGenerator.cs create mode 100644 src/Ocelot/Cache/DefaultCacheKeyGenerator.cs create mode 100644 test/Ocelot.AcceptanceTests/Caching/CachingTests.cs create mode 100644 test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs delete mode 100644 test/Ocelot.AcceptanceTests/CachingTests.cs delete mode 100644 test/Ocelot.AcceptanceTests/HttpClientCachingTests.cs delete mode 100644 test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs create mode 100644 test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs diff --git a/docs/features/caching.rst b/docs/features/caching.rst index a7cd4096f..f7118fe83 100644 --- a/docs/features/caching.rst +++ b/docs/features/caching.rst @@ -30,11 +30,14 @@ Finally, in order to use caching on a route in your Route configuration add this .. code-block:: json - "FileCacheOptions": { "TtlSeconds": 15, "Region": "europe-central" } + "FileCacheOptions": { "TtlSeconds": 15, "Region": "europe-central", "Header": "Authorization" } In this example **TtlSeconds** is set to 15 which means the cache will expire after 15 seconds. The **Region** represents a region of caching. +Additionally, if a header name is defined in the **Header** property, that header value is looked up by the key (header name) in the ``HttpRequest`` headers, +and if the header is found, its value will be included in caching key. This causes the cache to become invalid due to the header value changing. + If you look at the example `here `_ you can see how the cache manager is setup and then passed into the Ocelot ``AddCacheManager`` configuration method. You can use any settings supported by the **CacheManager** package and just pass them in. diff --git a/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs b/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs index e89265622..eb84b5250 100644 --- a/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs +++ b/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs @@ -34,7 +34,7 @@ public static IOcelotBuilder AddCacheManager(this IOcelotBuilder builder, Action builder.Services.AddSingleton>(fileConfigCacheManager); builder.Services.RemoveAll(typeof(ICacheKeyGenerator)); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/Ocelot/Cache/CacheKeyGenerator.cs b/src/Ocelot/Cache/CacheKeyGenerator.cs deleted file mode 100644 index e6ae88213..000000000 --- a/src/Ocelot/Cache/CacheKeyGenerator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Ocelot.Request.Middleware; - -namespace Ocelot.Cache -{ - public class CacheKeyGenerator : ICacheKeyGenerator - { - public string GenerateRequestCacheKey(DownstreamRequest downstreamRequest) - { - var downStreamUrlKeyBuilder = new StringBuilder($"{downstreamRequest.Method}-{downstreamRequest.OriginalString}"); - if (downstreamRequest.Content != null) - { - var requestContentString = Task.Run(async () => await downstreamRequest.Content.ReadAsStringAsync()).Result; - downStreamUrlKeyBuilder.Append(requestContentString); - } - - var hashedContent = MD5Helper.GenerateMd5(downStreamUrlKeyBuilder.ToString()); - return hashedContent; - } - } -} diff --git a/src/Ocelot/Cache/DefaultCacheKeyGenerator.cs b/src/Ocelot/Cache/DefaultCacheKeyGenerator.cs new file mode 100644 index 000000000..ac79fc5c5 --- /dev/null +++ b/src/Ocelot/Cache/DefaultCacheKeyGenerator.cs @@ -0,0 +1,46 @@ +using Ocelot.Configuration; +using Ocelot.Request.Middleware; + +namespace Ocelot.Cache; + +public class DefaultCacheKeyGenerator : ICacheKeyGenerator +{ + private const char Delimiter = '-'; + + public async ValueTask GenerateRequestCacheKey(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute) + { + var builder = new StringBuilder() + .Append(downstreamRequest.Method) + .Append(Delimiter) + .Append(downstreamRequest.OriginalString); + + var options = downstreamRoute?.CacheOptions ?? new(); + if (!string.IsNullOrEmpty(options.Header)) + { + var header = downstreamRequest.Headers + .FirstOrDefault(r => r.Key.Equals(options.Header, StringComparison.OrdinalIgnoreCase)) + .Value?.FirstOrDefault(); + + if (!string.IsNullOrEmpty(header)) + { + builder.Append(Delimiter) + .Append(header); + } + } + + if (!options.EnableContentHashing || !downstreamRequest.HasContent) + { + return MD5Helper.GenerateMd5(builder.ToString()); + } + + var requestContentString = await ReadContentAsync(downstreamRequest); + builder.Append(Delimiter) + .Append(requestContentString); + + return MD5Helper.GenerateMd5(builder.ToString()); + } + + private static Task ReadContentAsync(DownstreamRequest downstream) => downstream.HasContent + ? downstream?.Request?.Content?.ReadAsStringAsync() ?? Task.FromResult(string.Empty) + : Task.FromResult(string.Empty); +} diff --git a/src/Ocelot/Cache/ICacheKeyGenerator.cs b/src/Ocelot/Cache/ICacheKeyGenerator.cs index 32a1f989e..d2ccb0ef5 100644 --- a/src/Ocelot/Cache/ICacheKeyGenerator.cs +++ b/src/Ocelot/Cache/ICacheKeyGenerator.cs @@ -1,9 +1,10 @@ -using Ocelot.Request.Middleware; +using Ocelot.Configuration; +using Ocelot.Request.Middleware; namespace Ocelot.Cache { public interface ICacheKeyGenerator { - string GenerateRequestCacheKey(DownstreamRequest downstreamRequest); + ValueTask GenerateRequestCacheKey(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute); } } diff --git a/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs b/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs index 5e3158b48..750f28285 100644 --- a/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs +++ b/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs @@ -14,7 +14,7 @@ public class OutputCacheMiddleware : OcelotMiddleware IOcelotLoggerFactory loggerFactory, IOcelotCache outputCache, ICacheKeyGenerator cacheGenerator) - : base(loggerFactory.CreateLogger()) + : base(loggerFactory.CreateLogger()) { _next = next; _outputCache = outputCache; @@ -33,21 +33,16 @@ public async Task Invoke(HttpContext httpContext) var downstreamRequest = httpContext.Items.DownstreamRequest(); var downstreamUrlKey = $"{downstreamRequest.Method}-{downstreamRequest.OriginalString}"; - var downStreamRequestCacheKey = _cacheGenerator.GenerateRequestCacheKey(downstreamRequest); + var downStreamRequestCacheKey = await _cacheGenerator.GenerateRequestCacheKey(downstreamRequest, downstreamRoute); Logger.LogDebug(() => $"Started checking cache for the '{downstreamUrlKey}' key."); - var cached = _outputCache.Get(downStreamRequestCacheKey, downstreamRoute.CacheOptions.Region); - if (cached != null) { Logger.LogDebug(() => $"Cache entry exists for the '{downstreamUrlKey}' key."); - var response = CreateHttpResponseMessage(cached); SetHttpResponseMessageThisRequest(httpContext, response); - Logger.LogDebug(() => $"Finished returning of cached response for the '{downstreamUrlKey}' key."); - return; } @@ -58,24 +53,18 @@ public async Task Invoke(HttpContext httpContext) if (httpContext.Items.Errors().Count > 0) { Logger.LogDebug(() => $"There was a pipeline error for the '{downstreamUrlKey}' key."); - return; } var downstreamResponse = httpContext.Items.DownstreamResponse(); - cached = await CreateCachedResponse(downstreamResponse); _outputCache.Add(downStreamRequestCacheKey, cached, TimeSpan.FromSeconds(downstreamRoute.CacheOptions.TtlSeconds), downstreamRoute.CacheOptions.Region); - Logger.LogDebug(() => $"Finished response added to cache for the '{downstreamUrlKey}' key."); } - private static void SetHttpResponseMessageThisRequest(HttpContext context, - DownstreamResponse response) - { - context.Items.UpsertDownstreamResponse(response); - } + private static void SetHttpResponseMessageThisRequest(HttpContext context, DownstreamResponse response) + => context.Items.UpsertDownstreamResponse(response); internal DownstreamResponse CreateHttpResponseMessage(CachedResponse cached) { @@ -85,7 +74,6 @@ internal DownstreamResponse CreateHttpResponseMessage(CachedResponse cached) } var content = new MemoryStream(Convert.FromBase64String(cached.Body)); - var streamContent = new StreamContent(content); foreach (var header in cached.ContentHeaders) @@ -114,7 +102,6 @@ internal async Task CreateCachedResponse(DownstreamResponse resp } var contentHeaders = response?.Content?.Headers.ToDictionary(v => v.Key, v => v.Value); - var cached = new CachedResponse(statusCode, headers, body, contentHeaders, response.ReasonPhrase); return cached; } diff --git a/src/Ocelot/Configuration/CacheOptions.cs b/src/Ocelot/Configuration/CacheOptions.cs index d509b38e9..352b501d8 100644 --- a/src/Ocelot/Configuration/CacheOptions.cs +++ b/src/Ocelot/Configuration/CacheOptions.cs @@ -1,15 +1,39 @@ -namespace Ocelot.Configuration +using Ocelot.Request.Middleware; + +namespace Ocelot.Configuration { public class CacheOptions - { - public CacheOptions(int ttlSeconds, string region) + { + internal CacheOptions() { } + + public CacheOptions(int ttlSeconds, string region, string header) { TtlSeconds = ttlSeconds; - Region = region; + Region = region; + Header = header; + } + + public CacheOptions(int ttlSeconds, string region, string header, bool enableContentHashing) + { + TtlSeconds = ttlSeconds; + Region = region; + Header = header; + EnableContentHashing = enableContentHashing; } - public int TtlSeconds { get; } - - public string Region { get; } + public int TtlSeconds { get; } + public string Region { get; } + public string Header { get; } + + /// + /// Enables MD5 hash calculation of the of the object. + /// + /// + /// Default value is . No hashing by default. + /// + /// + /// if hashing is enabled, otherwise it is . + /// + public bool EnableContentHashing { get; } } } diff --git a/src/Ocelot/Configuration/Creator/RoutesCreator.cs b/src/Ocelot/Configuration/Creator/RoutesCreator.cs index 100c65116..8c1f1de63 100644 --- a/src/Ocelot/Configuration/Creator/RoutesCreator.cs +++ b/src/Ocelot/Configuration/Creator/RoutesCreator.cs @@ -122,7 +122,7 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf .WithClaimsToDownstreamPath(claimsToDownstreamPath) .WithRequestIdKey(requestIdKey) .WithIsCached(fileRouteOptions.IsCached) - .WithCacheOptions(new CacheOptions(fileRoute.FileCacheOptions.TtlSeconds, region)) + .WithCacheOptions(new CacheOptions(fileRoute.FileCacheOptions.TtlSeconds, region, fileRoute.FileCacheOptions.Header)) .WithDownstreamScheme(fileRoute.DownstreamScheme) .WithLoadBalancerOptions(lbOptions) .WithDownstreamAddresses(downstreamAddresses) diff --git a/src/Ocelot/Configuration/File/FileCacheOptions.cs b/src/Ocelot/Configuration/File/FileCacheOptions.cs index 65c481344..a1b1deed5 100644 --- a/src/Ocelot/Configuration/File/FileCacheOptions.cs +++ b/src/Ocelot/Configuration/File/FileCacheOptions.cs @@ -13,8 +13,9 @@ public FileCacheOptions(FileCacheOptions from) Region = from.Region; TtlSeconds = from.TtlSeconds; } - - public string Region { get; set; } + public int TtlSeconds { get; set; } + public string Region { get; set; } + public string Header { get; set; } } } diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index 4d198711e..93070dc11 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -116,7 +116,7 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); - Services.TryAddSingleton(); + Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton, OcelotConfigurationMonitor>(); diff --git a/src/Ocelot/Request/Middleware/DownstreamRequest.cs b/src/Ocelot/Request/Middleware/DownstreamRequest.cs index 18b8641e1..045720c37 100644 --- a/src/Ocelot/Request/Middleware/DownstreamRequest.cs +++ b/src/Ocelot/Request/Middleware/DownstreamRequest.cs @@ -6,6 +6,8 @@ public class DownstreamRequest { private readonly HttpRequestMessage _request; + public DownstreamRequest() { } + public DownstreamRequest(HttpRequestMessage request) { _request = request; @@ -14,13 +16,11 @@ public DownstreamRequest(HttpRequestMessage request) Scheme = _request.RequestUri.Scheme; Host = _request.RequestUri.Host; Port = _request.RequestUri.Port; - Headers = _request.Headers; AbsolutePath = _request.RequestUri.AbsolutePath; Query = _request.RequestUri.Query; - Content = _request.Content; } - public HttpRequestHeaders Headers { get; } + public HttpHeaders Headers { get => _request.Headers; } public string Method { get; } @@ -36,7 +36,9 @@ public DownstreamRequest(HttpRequestMessage request) public string Query { get; set; } - public HttpContent Content { get; set; } + public bool HasContent { get => _request?.Content != null; } + + public HttpRequestMessage Request { get => _request; } public HttpRequestMessage ToHttpRequestMessage() { diff --git a/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs b/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs index 5fef5a918..65cad3252 100644 --- a/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs +++ b/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs @@ -63,14 +63,14 @@ private void SetOcelotRequestId(HttpContext httpContext) } } - private static bool ShouldAddRequestId(RequestId requestId, HttpRequestHeaders headers) + private static bool ShouldAddRequestId(RequestId requestId, HttpHeaders headers) { return !string.IsNullOrEmpty(requestId?.RequestIdKey) && !string.IsNullOrEmpty(requestId.RequestIdValue) && !RequestIdInHeaders(requestId, headers); } - private static bool RequestIdInHeaders(RequestId requestId, HttpRequestHeaders headers) + private static bool RequestIdInHeaders(RequestId requestId, HttpHeaders headers) { return headers.TryGetValues(requestId.RequestIdKey, out var value); } diff --git a/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs new file mode 100644 index 000000000..4eb8a5bf3 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs @@ -0,0 +1,205 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration.File; + +namespace Ocelot.AcceptanceTests.Caching +{ + public sealed class CachingTests : IDisposable + { + private readonly Steps _steps; + private readonly ServiceHandler _serviceHandler; + + private const string HelloTomContent = "Hello from Tom"; + private const string HelloLauraContent = "Hello from Laura"; + + public CachingTests() + { + _serviceHandler = new ServiceHandler(); + _steps = new Steps(); + } + + [Fact] + public void Should_return_cached_response() + { + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + }; + var configuration = GivenFileConfiguration(port, options); + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, null, null)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .And(x => _steps.ThenTheContentLengthIs(HelloLauraContent.Length)) + .BDDfy(); + } + + [Fact] + public void Should_return_cached_response_with_expires_header() + { + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + }; + var configuration = GivenFileConfiguration(port, options); + var headerExpires = "Expires"; + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, headerExpires, "-1")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .And(x => _steps.ThenTheContentLengthIs(HelloLauraContent.Length)) + .And(x => _steps.ThenTheResponseBodyHeaderIs(headerExpires, "-1")) + .BDDfy(); + } + + [Fact] + public void Should_return_cached_response_when_using_jsonserialized_cache() + { + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + }; + var configuration = GivenFileConfiguration(port, options); + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, null, null)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningUsingJsonSerializedCache()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .BDDfy(); + } + + [Fact] + public void Should_not_return_cached_response_as_ttl_expires() + { + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 1, + }; + var configuration = GivenFileConfiguration(port, options); + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, null, null)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null)) + .And(x => GivenTheCacheExpires()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloTomContent)) + .BDDfy(); + } + + [Fact] + [Trait("Issue", "1172")] + public void Should_clean_cached_response_by_cache_header_via_new_caching_key() + { + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + Region = "europe-central", + Header = "Authorization", + }; + var configuration = GivenFileConfiguration(port, options); + var headerExpires = "Expires"; + + // Add to cache + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, headerExpires, options.TtlSeconds)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + + // Read from cache + .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, headerExpires, options.TtlSeconds / 2)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent)) + .And(x => _steps.ThenTheContentLengthIs(HelloLauraContent.Length)) + + // Clean cache by the header and cache new content + .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, headerExpires, -1)) + .And(x => _steps.GivenIAddAHeader(options.Header, "123")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloTomContent)) + .And(x => _steps.ThenTheContentLengthIs(HelloTomContent.Length)) + .BDDfy(); + } + + private static FileConfiguration GivenFileConfiguration(int port, FileCacheOptions cacheOptions) => new() + { + Routes = + [ + new FileRoute() + { + DownstreamPathTemplate = "/", + DownstreamHostAndPorts = + [ + new FileHostAndPort("localhost", port), + ], + DownstreamScheme = Uri.UriSchemeHttp, + UpstreamPathTemplate = "/", + UpstreamHttpMethod =["Get"], + FileCacheOptions = cacheOptions, + }, + ], + }; + + private static void GivenTheCacheExpires() + { + Thread.Sleep(1000); + } + + private void GivenTheServiceNowReturns(string url, HttpStatusCode statusCode, string responseBody, string key, object value) + { + _serviceHandler.Dispose(); + GivenThereIsAServiceRunningOn(url, statusCode, responseBody, key, value); + } + + private void GivenThereIsAServiceRunningOn(string url, HttpStatusCode statusCode, string responseBody, string key, object value) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + if (!string.IsNullOrEmpty(key) && value != null) + { + context.Response.Headers.Append(key, value.ToString()); + } + + context.Response.StatusCode = (int)statusCode; + await context.Response.WriteAsync(responseBody); + }); + } + + public void Dispose() + { + _serviceHandler?.Dispose(); + _steps.Dispose(); + } + } +} diff --git a/test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs b/test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs new file mode 100644 index 000000000..108fb6456 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using Ocelot.Configuration.File; +using Ocelot.Requester; +using System.Collections.Concurrent; + +namespace Ocelot.AcceptanceTests.Caching +{ + public sealed class HttpClientCachingTests : IDisposable + { + private readonly Steps _steps; + private readonly ServiceHandler _serviceHandler; + private const string HelloFromLaura = "Hello from Laura"; + + public HttpClientCachingTests() + { + _serviceHandler = new ServiceHandler(); + _steps = new Steps(); + } + + private FileRoute GivenRoute(int port, string template) => new() + { + DownstreamPathTemplate = template, + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = + [ + new("localhost", port), + ], + UpstreamPathTemplate = template, + UpstreamHttpMethod =["Get"], + }; + + private FileConfiguration GivenFileConfiguration(params FileRoute[] routes) + { + var config = new FileConfiguration(); + config.Routes.AddRange(routes); + return config; + } + + [Fact] + public void Should_cache_one_http_client_same_route() + { + var port = PortFinder.GetRandomPort(); + var configuration = GivenFileConfiguration( + GivenRoute(port, "/")); + var cache = new FakeHttpClientCache(); + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloFromLaura)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloFromLaura)) + .And(x => ThenTheCountShouldBe(cache, 1)) + .BDDfy(); + } + + [Fact] + public void Should_cache_two_http_client_different_route() + { + var port = PortFinder.GetRandomPort(); + var configuration = GivenFileConfiguration( + GivenRoute(port, "/"), + GivenRoute(port, "/two")); + var cache = new FakeHttpClientCache(); + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloFromLaura)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(HelloFromLaura)) + .And(x => ThenTheCountShouldBe(cache, 2)) + .BDDfy(); + } + + private static void ThenTheCountShouldBe(FakeHttpClientCache cache, int count) + => cache.Count.ShouldBe(count); + + private void GivenThereIsAServiceRunningOn(string baseUrl, HttpStatusCode statusCode, string responseBody) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, async context => + { + context.Response.StatusCode = (int)statusCode; + await context.Response.WriteAsync(responseBody); + }); + } + + public void Dispose() + { + _serviceHandler.Dispose(); + _steps.Dispose(); + } + + public class FakeHttpClientCache : IHttpClientCache + { + private readonly ConcurrentDictionary _httpClientsCache; + + public FakeHttpClientCache() + => _httpClientsCache = new ConcurrentDictionary(); + + public void Set(DownstreamRoute key, IHttpClient client, TimeSpan expirationTime) + => _httpClientsCache.AddOrUpdate(key, client, (k, oldValue) => client); + + public IHttpClient Get(DownstreamRoute key) + => _httpClientsCache.TryGetValue(key, out var client) ? client : null; + + public int Count => _httpClientsCache.Count; + } + } +} diff --git a/test/Ocelot.AcceptanceTests/CachingTests.cs b/test/Ocelot.AcceptanceTests/CachingTests.cs deleted file mode 100644 index fc1f91000..000000000 --- a/test/Ocelot.AcceptanceTests/CachingTests.cs +++ /dev/null @@ -1,228 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration.File; - -namespace Ocelot.AcceptanceTests -{ - public class CachingTests : IDisposable - { - private readonly Steps _steps; - private readonly ServiceHandler _serviceHandler; - - public CachingTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - } - - [Fact] - public void should_return_cached_response() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - FileCacheOptions = new FileCacheOptions - { - TtlSeconds = 100, - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", null, null)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .And(x => _steps.ThenTheContentLengthIs(16)) - .BDDfy(); - } - - [Fact] - public void should_return_cached_response_with_expires_header() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - FileCacheOptions = new FileCacheOptions - { - TtlSeconds = 100, - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", "Expires", "-1")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .And(x => _steps.ThenTheContentLengthIs(16)) - .And(x => _steps.ThenTheResponseBodyHeaderIs("Expires", "-1")) - .BDDfy(); - } - - [Fact] - public void should_return_cached_response_when_using_jsonserialized_cache() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - FileCacheOptions = new FileCacheOptions - { - TtlSeconds = 100, - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", null, null)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningUsingJsonSerializedCache()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_not_return_cached_response_as_ttl_expires() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - FileCacheOptions = new FileCacheOptions - { - TtlSeconds = 1, - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", null, null)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom")) - .And(x => GivenTheCacheExpires()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Tom")) - .BDDfy(); - } - - private static void GivenTheCacheExpires() - { - Thread.Sleep(1000); - } - - private void GivenTheServiceNowReturns(string url, int statusCode, string responseBody) - { - _serviceHandler.Dispose(); - GivenThereIsAServiceRunningOn(url, statusCode, responseBody, null, null); - } - - private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody, string key, string value) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(key)) - { - context.Response.Headers.Append(key, value); - } - - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - }); - } - - public void Dispose() - { - _serviceHandler?.Dispose(); - _steps.Dispose(); - } - } -} diff --git a/test/Ocelot.AcceptanceTests/HttpClientCachingTests.cs b/test/Ocelot.AcceptanceTests/HttpClientCachingTests.cs deleted file mode 100644 index eca147ac8..000000000 --- a/test/Ocelot.AcceptanceTests/HttpClientCachingTests.cs +++ /dev/null @@ -1,165 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; -using Ocelot.Configuration.File; -using Ocelot.Requester; -using System.Collections.Concurrent; - -namespace Ocelot.AcceptanceTests -{ - public class HttpClientCachingTests : IDisposable - { - private readonly Steps _steps; - private readonly ServiceHandler _serviceHandler; - - public HttpClientCachingTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - } - - [Fact] - public void should_cache_one_http_client_same_re_route() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - var cache = new FakeHttpClientCache(); - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .And(x => ThenTheCountShouldBe(cache, 1)) - .BDDfy(); - } - - [Fact] - public void should_cache_two_http_client_different_re_route() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - }, - new() - { - DownstreamPathTemplate = "/two", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/two", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - var cache = new FakeHttpClientCache(); - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .And(x => ThenTheCountShouldBe(cache, 2)) - .BDDfy(); - } - - private static void ThenTheCountShouldBe(FakeHttpClientCache cache, int count) - { - cache.Count.ShouldBe(count); - } - - private void GivenThereIsAServiceRunningOn(string baseUrl, int statusCode, string responseBody) - { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, async context => - { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - }); - } - - public void Dispose() - { - _serviceHandler.Dispose(); - _steps.Dispose(); - } - - public class FakeHttpClientCache : IHttpClientCache - { - private readonly ConcurrentDictionary _httpClientsCache; - - public FakeHttpClientCache() - { - _httpClientsCache = new ConcurrentDictionary(); - } - - public void Set(DownstreamRoute key, IHttpClient client, TimeSpan expirationTime) - { - _httpClientsCache.AddOrUpdate(key, client, (k, oldValue) => client); - } - - public IHttpClient Get(DownstreamRoute key) - { - //todo handle error? - return _httpClientsCache.TryGetValue(key, out var client) ? client : null; - } - - public int Count => _httpClientsCache.Count; - } - } -} diff --git a/test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs b/test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs deleted file mode 100644 index f71e684af..000000000 --- a/test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Ocelot.Cache; -using Ocelot.Request.Middleware; - -namespace Ocelot.UnitTests.Cache -{ - public class CacheKeyGeneratorTests - { - private readonly ICacheKeyGenerator _cacheKeyGenerator; - private readonly DownstreamRequest _downstreamRequest; - - public CacheKeyGeneratorTests() - { - _cacheKeyGenerator = new CacheKeyGenerator(); - _cacheKeyGenerator = new CacheKeyGenerator(); - _downstreamRequest = new DownstreamRequest(new HttpRequestMessage(HttpMethod.Get, "https://some.url/blah?abcd=123")); - } - - [Fact] - public void should_generate_cache_key_from_context() - { - this.Given(x => x.GivenCacheKeyFromContext(_downstreamRequest)) - .BDDfy(); - } - - private void GivenCacheKeyFromContext(DownstreamRequest downstreamRequest) - { - var generatedCacheKey = _cacheKeyGenerator.GenerateRequestCacheKey(downstreamRequest); - var cachekey = MD5Helper.GenerateMd5("GET-https://some.url/blah?abcd=123"); - generatedCacheKey.ShouldBe(cachekey); - } - } -} diff --git a/test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs b/test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs new file mode 100644 index 000000000..a81ec7d07 --- /dev/null +++ b/test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs @@ -0,0 +1,134 @@ +using Ocelot.Cache; +using Ocelot.Configuration; +using Ocelot.Configuration.Builder; +using Ocelot.Request.Middleware; +using System.Reflection; +using System.Text; + +namespace Ocelot.UnitTests.Cache; + +public sealed class DefaultCacheKeyGeneratorTests : IDisposable +{ + private readonly ICacheKeyGenerator _cacheKeyGenerator; + private readonly HttpRequestMessage _request; + + private const string verb = "GET"; + private const string url = "https://some.url/blah?abcd=123"; + private const string header = nameof(DefaultCacheKeyGeneratorTests); + private const string headerName = "auth"; + + public DefaultCacheKeyGeneratorTests() + { + _cacheKeyGenerator = new DefaultCacheKeyGenerator(); + + _request = new HttpRequestMessage + { + Method = new HttpMethod(verb), + RequestUri = new Uri(url), + }; + _request.Headers.Add(headerName, header); + } + + [Fact] + public void should_generate_cache_key_with_request_content() + { + const string noHeader = null; + const string content = nameof(should_generate_cache_key_with_request_content); + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}-{content}"); + CacheOptions options = new CacheOptions(100, "region", noHeader, true); + + this.Given(x => x.GivenDownstreamRoute(options)) + .And(x => GivenHasContent(content)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) + .BDDfy(); + } + + [Fact] + public void should_generate_cache_key_without_request_content() + { + CacheOptions options = null; + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}"); + + this.Given(x => x.GivenDownstreamRoute(options)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) + .BDDfy(); + } + + [Fact] + public void should_generate_cache_key_with_cache_options_header() + { + CacheOptions options = new CacheOptions(100, "region", headerName); + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}-{header}"); + + this.Given(x => x.GivenDownstreamRoute(options)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) + .BDDfy(); + } + + [Fact] + public void should_generate_cache_key_happy_path() + { + const string content = nameof(should_generate_cache_key_happy_path); + CacheOptions options = new CacheOptions(100, "region", headerName, true); + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}-{header}-{content}"); + + this.Given(x => x.GivenDownstreamRoute(options)) + .And(x => GivenHasContent(content)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) + .BDDfy(); + } + + private DownstreamRoute _downstreamRoute; + + private void GivenDownstreamRoute(CacheOptions options) + { + _downstreamRoute = new DownstreamRouteBuilder() + .WithKey("key1") + .WithCacheOptions(options) + .Build(); + } + + private void GivenHasContent(string content) + { + _request.Content = new StringContent(content); + } + + private string _generatedCacheKey; + + private async Task WhenGenerateRequestCacheKey() + { + _generatedCacheKey = await _cacheKeyGenerator.GenerateRequestCacheKey(new DownstreamRequest(_request), _downstreamRoute); + } + + private void ThenGeneratedCacheKeyIs(string expected) + { + _generatedCacheKey.ShouldBe(expected); + } + + public void Dispose() + { + _request.Dispose(); + } +} + +internal class HttpContentStub : HttpContent +{ + private readonly string _content; + private readonly MemoryStream _stream; + + public HttpContentStub(string content) + { + _content = content; + _stream = new MemoryStream(Encoding.ASCII.GetBytes(content)); + + var field = typeof(HttpContent).GetField("_bufferedContent", BindingFlags.NonPublic | BindingFlags.Instance); + field.SetValue(this, _stream); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) => throw new NotImplementedException(); + protected override bool TryComputeLength(out long length) => throw new NotImplementedException(); +} diff --git a/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs b/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs index b494cbe7f..bb7e829a9 100644 --- a/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs @@ -29,7 +29,7 @@ public OutputCacheMiddlewareTests() _cache = new Mock>(); _loggerFactory = new Mock(); _logger = new Mock(); - _cacheKeyGenerator = new CacheKeyGenerator(); + _cacheKeyGenerator = new DefaultCacheKeyGenerator(); _loggerFactory.Setup(x => x.CreateLogger()).Returns(_logger.Object); _next = context => Task.CompletedTask; _httpContext.Items.UpsertDownstreamRequest(new Ocelot.Request.Middleware.DownstreamRequest(new HttpRequestMessage(HttpMethod.Get, "https://some.url/blah?abcd=123"))); @@ -106,7 +106,7 @@ private void GivenTheDownstreamRouteIs() var route = new RouteBuilder() .WithDownstreamRoute(new DownstreamRouteBuilder() .WithIsCached(true) - .WithCacheOptions(new CacheOptions(100, "kanken")) + .WithCacheOptions(new CacheOptions(100, "kanken", null)) .WithUpstreamHttpMethod(new List { "Get" }) .Build()) .WithUpstreamHttpMethod(new List { "Get" }) diff --git a/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs b/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs index bb02d98b0..ee52d3785 100644 --- a/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs +++ b/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs @@ -25,14 +25,14 @@ public OutputCacheMiddlewareRealCacheTests() { _httpContext = new DefaultHttpContext(); _loggerFactory = new Mock(); - _logger = new Mock(); + _logger = new Mock(); _loggerFactory.Setup(x => x.CreateLogger()).Returns(_logger.Object); var cacheManagerOutputCache = CacheFactory.Build("OcelotOutputCache", x => { x.WithDictionaryHandle(); }); _cacheManager = new OcelotCacheManagerCache(cacheManagerOutputCache); - _cacheKeyGenerator = new CacheKeyGenerator(); + _cacheKeyGenerator = new DefaultCacheKeyGenerator(); _httpContext.Items.UpsertDownstreamRequest(new Ocelot.Request.Middleware.DownstreamRequest(new HttpRequestMessage(HttpMethod.Get, "https://some.url/blah?abcd=123"))); _next = context => Task.CompletedTask; _middleware = new OutputCacheMiddleware(_next, _loggerFactory.Object, _cacheManager, _cacheKeyGenerator); @@ -77,7 +77,7 @@ private void GivenTheDownstreamRouteIs() { var route = new DownstreamRouteBuilder() .WithIsCached(true) - .WithCacheOptions(new CacheOptions(100, "kanken")) + .WithCacheOptions(new CacheOptions(100, "kanken", null)) .WithUpstreamHttpMethod(new List { "Get" }) .Build(); From bb795876c493af958f1d58c852ea56e94263906d Mon Sep 17 00:00:00 2001 From: Guillaume Gnaegi <58469901+ggnaegi@users.noreply.github.com> Date: Mon, 18 Dec 2023 12:54:19 +0100 Subject: [PATCH 4/9] #1724 Reverting back HttpClient full buffering (#1853) --- src/Ocelot/Requester/HttpClientWrapper.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Ocelot/Requester/HttpClientWrapper.cs b/src/Ocelot/Requester/HttpClientWrapper.cs index 42e0b4d22..096f6afa5 100644 --- a/src/Ocelot/Requester/HttpClientWrapper.cs +++ b/src/Ocelot/Requester/HttpClientWrapper.cs @@ -12,13 +12,9 @@ public HttpClientWrapper(HttpClient client) Client = client; } - public Task SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken = default) + public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) { - // https://www.stevejgordon.co.uk/using-httpcompletionoption-responseheadersread-to-improve-httpclient-performance-dotnet - // When using this option, we avoid the intermediate MemoryStream buffer, instead of getting the content directly from the stream exposed on the Socket. - // This avoids unnecessary allocations which is a goal in highly optimised situations. - return Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return Client.SendAsync(request, cancellationToken); } } } From 5e7e76be65246362676851ab659fee47965ea0db Mon Sep 17 00:00:00 2001 From: Guillaume Gnaegi <58469901+ggnaegi@users.noreply.github.com> Date: Thu, 18 Jan 2024 12:43:09 +0100 Subject: [PATCH 5/9] #356 #695 #1924 Custom `HttpMessageInvoker` pooling (#1824) * first version * first working version of the new http client pool * Some cleanup, removing classes that aren't used * some more cleanup * forgot passing PooledConnectionLifetime * adding todo for connection pool and request timeouts * some code cleanup * ongoing process refactoring tests * a little mistake with big effects * Several refactorings, disposing http response message to ensure that the connection is freed. Moving errors from Polly provider to Errors\QoS. * providing some comments * adding response body benchmark * some minor changes in MessageInvokerPool. * using context.RequestAborted in responder middleware (copying the response body from downstream service to the http context) * Fix style warnings * code review * moving response.Content.ReadAsStreamAsync nearer to CopyToAsync with using. Making sure, that the content stream is disposed * HttpResponse.Content never returns null (from .net 5 onwards) * adding more unit tests (validating, log warning if passthrough certificate, cookies are returned, message invoker timeout) * adding a tolerance margin * adding new acceptance test, checking memory usage. Needs to be compared with current implementation first. * Review tests by @raman-m * Update src/Ocelot/Configuration/HttpHandlerOptions.cs * Update src/Ocelot/Middleware/DownstreamResponse.cs * Update src/Ocelot/Requester/MessageInvokerPool.cs * Update src/Ocelot/Requester/HttpExceptionToErrorMapper.cs * Update src/Ocelot/Responder/HttpContextResponder.cs * Update src/Ocelot/Middleware/DownstreamResponse.cs * Use null-coalescing operator for `Nullable` obj * some modifications in the acceptance test, adding a tolerance of 1 Mb * finalizing content tests. Making sure, that the downstream response body is not copied by the api gateway. * adapting tolerances * Disable StyleCop rule SA1010 which is in conflict with collection initialization block vs whitespace. More: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1010.md * Refactor Content tests --------- Co-authored-by: Raman Maksimchuk --- codeanalysis.ruleset | 1 + .../OcelotBuilderExtensions.cs | 1 + src/Ocelot.Provider.Polly/PollyQoSProvider.cs | 13 +- .../RequestTimedOutError.cs | 12 - .../Creator/HttpHandlerOptionsCreator.cs | 6 +- .../File/FileHttpHandlerOptions.cs | 5 +- .../Configuration/HttpHandlerOptions.cs | 39 +- .../HttpHandlerOptionsBuilder.cs | 13 +- .../DependencyInjection/OcelotBuilder.cs | 3 +- src/Ocelot/Errors/QoS/RequestTimedOutError.cs | 11 + src/Ocelot/Middleware/DownstreamResponse.cs | 44 +- src/Ocelot/Requester/HttpClientBuilder.cs | 122 ----- .../Requester/HttpClientHttpRequester.cs | 52 -- src/Ocelot/Requester/HttpClientWrapper.cs | 20 - .../Requester/HttpExceptionToErrorMapper.cs | 13 +- src/Ocelot/Requester/IHttpClient.cs | 9 - src/Ocelot/Requester/IHttpClientBuilder.cs | 11 - src/Ocelot/Requester/IHttpClientCache.cs | 11 - src/Ocelot/Requester/IMessageInvokerPool.cs | 25 + src/Ocelot/Requester/MemoryHttpClientCache.cs | 25 - .../Requester/MessageInvokerHttpRequester.cs | 38 ++ src/Ocelot/Requester/MessageInvokerPool.cs | 110 +++++ .../Middleware/HttpRequesterMiddleware.cs | 5 - .../Requester/ServiceCollectionExtensions.cs | 12 + .../Requester/TimeoutDelegatingHandler.cs | 31 ++ src/Ocelot/Responder/HttpContextResponder.cs | 132 +++-- .../Middleware/ResponderMiddleware.cs | 38 +- .../Caching/HttpClientCachingTests.cs | 120 ----- test/Ocelot.AcceptanceTests/ContentTests.cs | 188 +++---- test/Ocelot.AcceptanceTests/PollyQoSTests.cs | 48 +- test/Ocelot.AcceptanceTests/Steps.cs | 32 +- .../Ocelot.Benchmarks/DictionaryBenchmarks.cs | 70 --- test/Ocelot.Benchmarks/Program.cs | 2 +- test/Ocelot.Benchmarks/ResponseBenchmarks.cs | 258 ++++++++++ .../AdministrationTests.cs | 2 +- .../HttpHandlerOptionsCreatorTests.cs | 25 +- ...atingHandlerHandlerProviderFactoryTests.cs | 40 +- .../Requester/HttpClientBuilderTests.cs | 462 ------------------ .../Requester/HttpClientHttpRequesterTest.cs | 188 ------- .../Requester/MessageInvokerPoolTests.cs | 341 +++++++++++++ 40 files changed, 1192 insertions(+), 1386 deletions(-) delete mode 100644 src/Ocelot.Provider.Polly/RequestTimedOutError.cs create mode 100644 src/Ocelot/Errors/QoS/RequestTimedOutError.cs delete mode 100644 src/Ocelot/Requester/HttpClientBuilder.cs delete mode 100644 src/Ocelot/Requester/HttpClientHttpRequester.cs delete mode 100644 src/Ocelot/Requester/HttpClientWrapper.cs delete mode 100644 src/Ocelot/Requester/IHttpClient.cs delete mode 100644 src/Ocelot/Requester/IHttpClientBuilder.cs delete mode 100644 src/Ocelot/Requester/IHttpClientCache.cs create mode 100644 src/Ocelot/Requester/IMessageInvokerPool.cs delete mode 100644 src/Ocelot/Requester/MemoryHttpClientCache.cs create mode 100644 src/Ocelot/Requester/MessageInvokerHttpRequester.cs create mode 100644 src/Ocelot/Requester/MessageInvokerPool.cs create mode 100644 src/Ocelot/Requester/ServiceCollectionExtensions.cs create mode 100644 src/Ocelot/Requester/TimeoutDelegatingHandler.cs delete mode 100644 test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs delete mode 100644 test/Ocelot.Benchmarks/DictionaryBenchmarks.cs create mode 100644 test/Ocelot.Benchmarks/ResponseBenchmarks.cs delete mode 100644 test/Ocelot.UnitTests/Requester/HttpClientBuilderTests.cs delete mode 100644 test/Ocelot.UnitTests/Requester/HttpClientHttpRequesterTest.cs create mode 100644 test/Ocelot.UnitTests/Requester/MessageInvokerPoolTests.cs diff --git a/codeanalysis.ruleset b/codeanalysis.ruleset index 0198bd195..096807342 100644 --- a/codeanalysis.ruleset +++ b/codeanalysis.ruleset @@ -18,6 +18,7 @@ + diff --git a/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs b/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs index c37c690d8..a12633683 100644 --- a/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs +++ b/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs @@ -3,6 +3,7 @@ using Ocelot.Configuration; using Ocelot.DependencyInjection; using Ocelot.Errors; +using Ocelot.Errors.QoS; using Ocelot.Logging; using Ocelot.Provider.Polly.Interfaces; using Ocelot.Requester; diff --git a/src/Ocelot.Provider.Polly/PollyQoSProvider.cs b/src/Ocelot.Provider.Polly/PollyQoSProvider.cs index edd460e77..7a9465a22 100644 --- a/src/Ocelot.Provider.Polly/PollyQoSProvider.cs +++ b/src/Ocelot.Provider.Polly/PollyQoSProvider.cs @@ -13,6 +13,9 @@ public class PollyQoSProvider : IPollyQoSProvider private readonly object _lockObject = new(); private readonly IOcelotLogger _logger; + //todo: this should be configurable and available as global config parameter in ocelot.json + public const int DefaultRequestTimeoutSeconds = 90; + private readonly HashSet _serverErrorCodes = new() { HttpStatusCode.InternalServerError, @@ -63,14 +66,20 @@ private PollyPolicyWrapper PollyPolicyWrapperFactory(Downst .Or() .CircuitBreakerAsync(route.QosOptions.ExceptionsAllowedBeforeBreaking, durationOfBreak: TimeSpan.FromMilliseconds(route.QosOptions.DurationOfBreak), - onBreak: (ex, breakDelay) => _logger.LogError(info + $"Breaking the circuit for {breakDelay.TotalMilliseconds} ms!", ex.Exception), + onBreak: (ex, breakDelay) => + _logger.LogError(info + $"Breaking the circuit for {breakDelay.TotalMilliseconds} ms!", + ex.Exception), onReset: () => _logger.LogDebug(info + "Call OK! Closed the circuit again."), onHalfOpen: () => _logger.LogDebug(info + "Half-open; Next call is a trial.")); } + // No default set for polly timeout at the minute. + // Since a user could potentially set timeout value = 0, we need to handle this case. + // TODO throw an exception if the user sets timeout value = 0 or at least return a warning + // TODO the design in DelegatingHandlerHandlerFactory should be reviewed var timeoutPolicy = Policy .TimeoutAsync( - TimeSpan.FromMilliseconds(route.QosOptions.TimeoutValue), + TimeSpan.FromMilliseconds(route.QosOptions.TimeoutValue), TimeoutStrategy.Pessimistic); return new PollyPolicyWrapper(exceptionsAllowedBeforeBreakingPolicy, timeoutPolicy); diff --git a/src/Ocelot.Provider.Polly/RequestTimedOutError.cs b/src/Ocelot.Provider.Polly/RequestTimedOutError.cs deleted file mode 100644 index 662e952ab..000000000 --- a/src/Ocelot.Provider.Polly/RequestTimedOutError.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Ocelot.Errors; - -namespace Ocelot.Provider.Polly -{ - public class RequestTimedOutError : Error - { - public RequestTimedOutError(Exception exception) - : base($"Timeout making http request, exception: {exception}", OcelotErrorCode.RequestTimedOutError, 503) - { - } - } -} diff --git a/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs b/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs index 133fa7020..e7e963c34 100644 --- a/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs @@ -8,6 +8,9 @@ public class HttpHandlerOptionsCreator : IHttpHandlerOptionsCreator { private readonly ITracer _tracer; + //todo: this should be configurable and available as global config parameter in ocelot.json + public const int DefaultPooledConnectionLifetimeSeconds = 120; + public HttpHandlerOptionsCreator(IServiceProvider services) { _tracer = services.GetService(); @@ -19,9 +22,10 @@ public HttpHandlerOptions Create(FileHttpHandlerOptions options) //be sure that maxConnectionPerServer is in correct range of values var maxConnectionPerServer = (options.MaxConnectionsPerServer > 0) ? options.MaxConnectionsPerServer : int.MaxValue; + var pooledConnectionLifetime = TimeSpan.FromSeconds(options.PooledConnectionLifetimeSeconds ?? DefaultPooledConnectionLifetimeSeconds); return new HttpHandlerOptions(options.AllowAutoRedirect, - options.UseCookieContainer, useTracing, options.UseProxy, maxConnectionPerServer); + options.UseCookieContainer, useTracing, options.UseProxy, maxConnectionPerServer, pooledConnectionLifetime); } } } diff --git a/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs b/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs index 33e1a15cb..4f7f14408 100644 --- a/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs +++ b/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs @@ -8,6 +8,7 @@ public FileHttpHandlerOptions() MaxConnectionsPerServer = int.MaxValue; UseCookieContainer = false; UseProxy = true; + PooledConnectionLifetimeSeconds = null; } public FileHttpHandlerOptions(FileHttpHandlerOptions from) @@ -16,12 +17,14 @@ public FileHttpHandlerOptions(FileHttpHandlerOptions from) MaxConnectionsPerServer = from.MaxConnectionsPerServer; UseCookieContainer = from.UseCookieContainer; UseProxy = from.UseProxy; + PooledConnectionLifetimeSeconds = from.PooledConnectionLifetimeSeconds; } public bool AllowAutoRedirect { get; set; } public int MaxConnectionsPerServer { get; set; } public bool UseCookieContainer { get; set; } public bool UseProxy { get; set; } - public bool UseTracing { get; set; } + public bool UseTracing { get; set; } + public int? PooledConnectionLifetimeSeconds { get; set; } } } diff --git a/src/Ocelot/Configuration/HttpHandlerOptions.cs b/src/Ocelot/Configuration/HttpHandlerOptions.cs index 6976c3f46..c70cca9b7 100644 --- a/src/Ocelot/Configuration/HttpHandlerOptions.cs +++ b/src/Ocelot/Configuration/HttpHandlerOptions.cs @@ -1,26 +1,27 @@ namespace Ocelot.Configuration { /// - /// Describes configuration parameters for http handler, - /// that is created to handle a request to service. + /// Describes configuration parameters for http handler, that is created to handle a request to service. /// public class HttpHandlerOptions { - public HttpHandlerOptions(bool allowAutoRedirect, bool useCookieContainer, bool useTracing, bool useProxy, int maxConnectionsPerServer) - { - AllowAutoRedirect = allowAutoRedirect; - UseCookieContainer = useCookieContainer; - UseTracing = useTracing; - UseProxy = useProxy; - MaxConnectionsPerServer = maxConnectionsPerServer; + public HttpHandlerOptions(bool allowAutoRedirect, bool useCookieContainer, bool useTracing, bool useProxy, + int maxConnectionsPerServer, TimeSpan pooledConnectionLifeTime) + { + AllowAutoRedirect = allowAutoRedirect; + UseCookieContainer = useCookieContainer; + UseTracing = useTracing; + UseProxy = useProxy; + MaxConnectionsPerServer = maxConnectionsPerServer; + PooledConnectionLifeTime = pooledConnectionLifeTime; } /// /// Specify if auto redirect is enabled. /// /// AllowAutoRedirect. - public bool AllowAutoRedirect { get; } - + public bool AllowAutoRedirect { get; } + /// /// Specify is handler has to use a cookie container. /// @@ -31,18 +32,24 @@ public HttpHandlerOptions(bool allowAutoRedirect, bool useCookieContainer, bool /// Specify is handler has to use a opentracing. /// /// UseTracing. - public bool UseTracing { get; } - + public bool UseTracing { get; } + /// /// Specify if handler has to use a proxy. /// /// UseProxy. - public bool UseProxy { get; } - + public bool UseProxy { get; } + /// /// Specify the maximum of concurrent connection to a network endpoint. /// /// MaxConnectionsPerServer. public int MaxConnectionsPerServer { get; } + + /// + /// Specify the maximum of time a connection can be pooled. + /// + /// PooledConnectionLifeTime. + public TimeSpan PooledConnectionLifeTime { get; } } -} +} diff --git a/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs b/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs index bc96296d6..ff0f87fe1 100644 --- a/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs +++ b/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs @@ -1,4 +1,6 @@ -namespace Ocelot.Configuration +using Ocelot.Configuration.Creator; + +namespace Ocelot.Configuration { public class HttpHandlerOptionsBuilder { @@ -7,6 +9,7 @@ public class HttpHandlerOptionsBuilder private bool _useTracing; private bool _useProxy; private int _maxConnectionPerServer; + private TimeSpan _pooledConnectionLifetime = TimeSpan.FromSeconds(HttpHandlerOptionsCreator.DefaultPooledConnectionLifetimeSeconds); public HttpHandlerOptionsBuilder WithAllowAutoRedirect(bool input) { @@ -38,9 +41,15 @@ public HttpHandlerOptionsBuilder WithUseMaxConnectionPerServer(int maxConnection return this; } + public HttpHandlerOptionsBuilder WithPooledConnectionLifetimeSeconds(TimeSpan pooledConnectionLifetime) + { + _pooledConnectionLifetime = pooledConnectionLifetime; + return this; + } + public HttpHandlerOptions Build() { - return new HttpHandlerOptions(_allowAutoRedirect, _useCookieContainer, _useTracing, _useProxy, _maxConnectionPerServer); + return new HttpHandlerOptions(_allowAutoRedirect, _useCookieContainer, _useTracing, _useProxy, _maxConnectionPerServer, _pooledConnectionLifetime); } } } diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index 93070dc11..a72ec3cbf 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -107,11 +107,9 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.AddSingleton(); Services.AddSingleton(); Services.TryAddSingleton(); - Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); - Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); @@ -119,6 +117,7 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton, OcelotConfigurationMonitor>(); + Services.AddOcelotMessageInvokerPool(); // See this for why we register this as singleton: // http://stackoverflow.com/questions/37371264/invalidoperationexception-unable-to-resolve-service-for-type-microsoft-aspnetc diff --git a/src/Ocelot/Errors/QoS/RequestTimedOutError.cs b/src/Ocelot/Errors/QoS/RequestTimedOutError.cs new file mode 100644 index 000000000..122d7af7d --- /dev/null +++ b/src/Ocelot/Errors/QoS/RequestTimedOutError.cs @@ -0,0 +1,11 @@ +using StatusCode = System.Net.HttpStatusCode; + +namespace Ocelot.Errors.QoS; + +public class RequestTimedOutError : Error +{ + public RequestTimedOutError(Exception exception) + : base($"Timeout making http request, exception: {exception}", OcelotErrorCode.RequestTimedOutError, (int)StatusCode.ServiceUnavailable) + { + } +} diff --git a/src/Ocelot/Middleware/DownstreamResponse.cs b/src/Ocelot/Middleware/DownstreamResponse.cs index f8275351e..e4e341ef3 100644 --- a/src/Ocelot/Middleware/DownstreamResponse.cs +++ b/src/Ocelot/Middleware/DownstreamResponse.cs @@ -1,21 +1,29 @@ namespace Ocelot.Middleware { - public class DownstreamResponse + public class DownstreamResponse : IDisposable { - public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, List
headers, string reasonPhrase) + // To detect redundant calls + private bool _disposedValue; + private readonly HttpResponseMessage _responseMessage; + + public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, List
headers, + string reasonPhrase) { Content = content; StatusCode = statusCode; - Headers = headers ?? new List
(); + Headers = headers ?? new(); ReasonPhrase = reasonPhrase; } public DownstreamResponse(HttpResponseMessage response) - : this(response.Content, response.StatusCode, response.Headers.Select(x => new Header(x.Key, x.Value)).ToList(), response.ReasonPhrase) + : this(response.Content, response.StatusCode, + response.Headers.Select(x => new Header(x.Key, x.Value)).ToList(), response.ReasonPhrase) { + _responseMessage = response; } - public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, IEnumerable>> headers, string reasonPhrase) + public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, + IEnumerable>> headers, string reasonPhrase) : this(content, statusCode, headers.Select(x => new Header(x.Key, x.Value)).ToList(), reasonPhrase) { } @@ -24,5 +32,31 @@ public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, IEnume public HttpStatusCode StatusCode { get; } public List
Headers { get; } public string ReasonPhrase { get; } + + // Public implementation of Dispose pattern callable by consumers. + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// We should make sure we dispose the content and response message to close the connection to the downstream service. + /// + protected virtual void Dispose(bool disposing) + { + if (_disposedValue) + { + return; + } + + if (disposing) + { + Content?.Dispose(); + _responseMessage?.Dispose(); + } + + _disposedValue = true; + } } } diff --git a/src/Ocelot/Requester/HttpClientBuilder.cs b/src/Ocelot/Requester/HttpClientBuilder.cs deleted file mode 100644 index 99b2bec3e..000000000 --- a/src/Ocelot/Requester/HttpClientBuilder.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Ocelot.Configuration; -using Ocelot.Logging; - -namespace Ocelot.Requester -{ - public class HttpClientBuilder : IHttpClientBuilder - { - private readonly IDelegatingHandlerHandlerFactory _factory; - private readonly IHttpClientCache _cacheHandlers; - private readonly IOcelotLogger _logger; - private DownstreamRoute _cacheKey; - private HttpClient _httpClient; - private IHttpClient _client; - private readonly TimeSpan _defaultTimeout; - - public HttpClientBuilder( - IDelegatingHandlerHandlerFactory factory, - IHttpClientCache cacheHandlers, - IOcelotLogger logger) - { - _factory = factory; - _cacheHandlers = cacheHandlers; - _logger = logger; - - // This is hardcoded at the moment but can easily be added to configuration - // if required by a user request. - _defaultTimeout = TimeSpan.FromSeconds(90); - } - - public IHttpClient Create(DownstreamRoute downstreamRoute) - { - _cacheKey = downstreamRoute; - - var httpClient = _cacheHandlers.Get(_cacheKey); - - if (httpClient != null) - { - _client = httpClient; - return httpClient; - } - - var handler = CreateHandler(downstreamRoute); - - if (downstreamRoute.DangerousAcceptAnyServerCertificateValidator) - { - handler.ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; - - _logger - .LogWarning(() => $"You have ignored all SSL warnings by using DangerousAcceptAnyServerCertificateValidator for this DownstreamRoute, UpstreamPathTemplate: {downstreamRoute.UpstreamPathTemplate}, DownstreamPathTemplate: {downstreamRoute.DownstreamPathTemplate}"); - } - - var timeout = downstreamRoute.QosOptions.TimeoutValue == 0 - ? _defaultTimeout - : TimeSpan.FromMilliseconds(downstreamRoute.QosOptions.TimeoutValue); - - _httpClient = new HttpClient(CreateHttpMessageHandler(handler, downstreamRoute)) - { - Timeout = timeout, - }; - - _client = new HttpClientWrapper(_httpClient); - - return _client; - } - - private static HttpClientHandler CreateHandler(DownstreamRoute downstreamRoute) - { - // Dont' create the CookieContainer if UseCookies is not set or the HttpClient will complain - // under .Net Full Framework - var useCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer; - - return useCookies ? UseCookiesHandler(downstreamRoute) : UseNonCookiesHandler(downstreamRoute); - } - - private static HttpClientHandler UseNonCookiesHandler(DownstreamRoute downstreamRoute) - { - return new HttpClientHandler - { - AllowAutoRedirect = downstreamRoute.HttpHandlerOptions.AllowAutoRedirect, - UseCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer, - UseProxy = downstreamRoute.HttpHandlerOptions.UseProxy, - MaxConnectionsPerServer = downstreamRoute.HttpHandlerOptions.MaxConnectionsPerServer, - }; - } - - private static HttpClientHandler UseCookiesHandler(DownstreamRoute downstreamRoute) - { - return new HttpClientHandler - { - AllowAutoRedirect = downstreamRoute.HttpHandlerOptions.AllowAutoRedirect, - UseCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer, - UseProxy = downstreamRoute.HttpHandlerOptions.UseProxy, - MaxConnectionsPerServer = downstreamRoute.HttpHandlerOptions.MaxConnectionsPerServer, - CookieContainer = new CookieContainer(), - }; - } - - public void Save() - { - _cacheHandlers.Set(_cacheKey, _client, TimeSpan.FromHours(24)); - } - - private HttpMessageHandler CreateHttpMessageHandler(HttpMessageHandler httpMessageHandler, DownstreamRoute request) - { - //todo handle error - var handlers = _factory.Get(request).Data; - - handlers - .Select(handler => handler) - .Reverse() - .ToList() - .ForEach(handler => - { - var delegatingHandler = handler(); - delegatingHandler.InnerHandler = httpMessageHandler; - httpMessageHandler = delegatingHandler; - }); - return httpMessageHandler; - } - } -} diff --git a/src/Ocelot/Requester/HttpClientHttpRequester.cs b/src/Ocelot/Requester/HttpClientHttpRequester.cs deleted file mode 100644 index cf3044681..000000000 --- a/src/Ocelot/Requester/HttpClientHttpRequester.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Logging; -using Ocelot.Middleware; -using Ocelot.Responses; - -namespace Ocelot.Requester -{ - public class HttpClientHttpRequester : IHttpRequester - { - private readonly IHttpClientCache _cacheHandlers; - private readonly IOcelotLogger _logger; - private readonly IDelegatingHandlerHandlerFactory _factory; - private readonly IExceptionToErrorMapper _mapper; - - public HttpClientHttpRequester(IOcelotLoggerFactory loggerFactory, - IHttpClientCache cacheHandlers, - IDelegatingHandlerHandlerFactory factory, - IExceptionToErrorMapper mapper) - { - _logger = loggerFactory.CreateLogger(); - _cacheHandlers = cacheHandlers; - _factory = factory; - _mapper = mapper; - } - - public async Task> GetResponse(HttpContext httpContext) - { - var builder = new HttpClientBuilder(_factory, _cacheHandlers, _logger); - - var downstreamRoute = httpContext.Items.DownstreamRoute(); - - var downstreamRequest = httpContext.Items.DownstreamRequest(); - - var httpClient = builder.Create(downstreamRoute); - - try - { - var response = await httpClient.SendAsync(downstreamRequest.ToHttpRequestMessage(), httpContext.RequestAborted); - return new OkResponse(response); - } - catch (Exception exception) - { - var error = _mapper.Map(exception); - return new ErrorResponse(error); - } - finally - { - builder.Save(); - } - } - } -} diff --git a/src/Ocelot/Requester/HttpClientWrapper.cs b/src/Ocelot/Requester/HttpClientWrapper.cs deleted file mode 100644 index 096f6afa5..000000000 --- a/src/Ocelot/Requester/HttpClientWrapper.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Ocelot.Requester -{ - /// - /// This class was made to make unit testing easier when HttpClient is used. - /// - public class HttpClientWrapper : IHttpClient - { - public HttpClient Client { get; } - - public HttpClientWrapper(HttpClient client) - { - Client = client; - } - - public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) - { - return Client.SendAsync(request, cancellationToken); - } - } -} diff --git a/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs b/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs index 62b03cfa9..dad0e856c 100644 --- a/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs +++ b/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs @@ -1,10 +1,12 @@ using Microsoft.Extensions.DependencyInjection; using Ocelot.Errors; +using Ocelot.Errors.QoS; namespace Ocelot.Requester { public class HttpExceptionToErrorMapper : IExceptionToErrorMapper { + /// This is a dictionary of custom mappers for exceptions. private readonly Dictionary> _mappers; public HttpExceptionToErrorMapper(IServiceProvider serviceProvider) @@ -16,17 +18,26 @@ public Error Map(Exception exception) { var type = exception.GetType(); + // If there is a custom mapper for this exception type, use it + // The idea is the following: When implementing features or providers, + // you can provide a custom mapper if (_mappers != null && _mappers.TryGetValue(type, out var mapper)) { return mapper(exception); } + // here are mapped the exceptions thrown from Ocelot core application + if (type == typeof(TimeoutException)) + { + return new RequestTimedOutError(exception); + } + if (type == typeof(OperationCanceledException) || type.IsSubclassOf(typeof(OperationCanceledException))) { return new RequestCanceledError(exception.Message); } - if (type == typeof(HttpRequestException)) + if (type == typeof(HttpRequestException) || type == typeof(TimeoutException)) { return new ConnectionToDownstreamServiceError(exception); } diff --git a/src/Ocelot/Requester/IHttpClient.cs b/src/Ocelot/Requester/IHttpClient.cs deleted file mode 100644 index 3d106def2..000000000 --- a/src/Ocelot/Requester/IHttpClient.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Ocelot.Requester -{ - public interface IHttpClient - { - HttpClient Client { get; } - - Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default); - } -} diff --git a/src/Ocelot/Requester/IHttpClientBuilder.cs b/src/Ocelot/Requester/IHttpClientBuilder.cs deleted file mode 100644 index b284a69ba..000000000 --- a/src/Ocelot/Requester/IHttpClientBuilder.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Ocelot.Configuration; - -namespace Ocelot.Requester -{ - public interface IHttpClientBuilder - { - IHttpClient Create(DownstreamRoute downstreamRoute); - - void Save(); - } -} diff --git a/src/Ocelot/Requester/IHttpClientCache.cs b/src/Ocelot/Requester/IHttpClientCache.cs deleted file mode 100644 index eeb3fa7b7..000000000 --- a/src/Ocelot/Requester/IHttpClientCache.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Ocelot.Configuration; - -namespace Ocelot.Requester -{ - public interface IHttpClientCache - { - IHttpClient Get(DownstreamRoute key); - - void Set(DownstreamRoute key, IHttpClient handler, TimeSpan expirationTime); - } -} diff --git a/src/Ocelot/Requester/IMessageInvokerPool.cs b/src/Ocelot/Requester/IMessageInvokerPool.cs new file mode 100644 index 000000000..f8d5edf73 --- /dev/null +++ b/src/Ocelot/Requester/IMessageInvokerPool.cs @@ -0,0 +1,25 @@ +using Ocelot.Configuration; + +namespace Ocelot.Requester; + +/// +/// A pool implementation for pooling. +/// +/// Largely inspired by StackExchange implementation. +/// Link: StackExchange.Utils.DefaultHttpClientPool. +/// +/// +public interface IMessageInvokerPool +{ + /// + /// Gets a client for the specified . + /// + /// The route to get a Message Invoker for. + /// A from the pool. + HttpMessageInvoker Get(DownstreamRoute downstreamRoute); + + /// + /// Clears the pool, in case you need to. + /// + void Clear(); +} diff --git a/src/Ocelot/Requester/MemoryHttpClientCache.cs b/src/Ocelot/Requester/MemoryHttpClientCache.cs deleted file mode 100644 index 60f90c1a6..000000000 --- a/src/Ocelot/Requester/MemoryHttpClientCache.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Ocelot.Configuration; - -namespace Ocelot.Requester -{ - public class MemoryHttpClientCache : IHttpClientCache - { - private readonly ConcurrentDictionary _httpClientsCache; - - public MemoryHttpClientCache() - { - _httpClientsCache = new ConcurrentDictionary(); - } - - public void Set(DownstreamRoute key, IHttpClient client, TimeSpan expirationTime) - { - _httpClientsCache.AddOrUpdate(key, client, (k, oldValue) => client); - } - - public IHttpClient Get(DownstreamRoute key) - { - //todo handle error? - return _httpClientsCache.TryGetValue(key, out var client) ? client : null; - } - } -} diff --git a/src/Ocelot/Requester/MessageInvokerHttpRequester.cs b/src/Ocelot/Requester/MessageInvokerHttpRequester.cs new file mode 100644 index 000000000..ea80de76e --- /dev/null +++ b/src/Ocelot/Requester/MessageInvokerHttpRequester.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Logging; +using Ocelot.Middleware; +using Ocelot.Responses; + +namespace Ocelot.Requester; + +public class MessageInvokerHttpRequester : IHttpRequester +{ + private readonly IOcelotLogger _logger; + private readonly IExceptionToErrorMapper _mapper; + private readonly IMessageInvokerPool _messageHandlerPool; + + public MessageInvokerHttpRequester(IOcelotLoggerFactory loggerFactory, + IMessageInvokerPool messageHandlerPool, + IExceptionToErrorMapper mapper) + { + _logger = loggerFactory.CreateLogger(); + _messageHandlerPool = messageHandlerPool; + _mapper = mapper; + } + + public async Task> GetResponse(HttpContext httpContext) + { + var downstreamRequest = httpContext.Items.DownstreamRequest(); + var messageInvoker = _messageHandlerPool.Get(httpContext.Items.DownstreamRoute()); + try + { + var response = await messageInvoker.SendAsync(downstreamRequest.ToHttpRequestMessage(), httpContext.RequestAborted); + return new OkResponse(response); + } + catch (Exception exception) + { + var error = _mapper.Map(exception); + return new ErrorResponse(error); + } + } +} diff --git a/src/Ocelot/Requester/MessageInvokerPool.cs b/src/Ocelot/Requester/MessageInvokerPool.cs new file mode 100644 index 000000000..32d9b5235 --- /dev/null +++ b/src/Ocelot/Requester/MessageInvokerPool.cs @@ -0,0 +1,110 @@ +using Ocelot.Configuration; +using Ocelot.Logging; +using System.Net.Security; + +namespace Ocelot.Requester; + +public class MessageInvokerPool : IMessageInvokerPool +{ + /// + /// TODO This should be configurable and available as global config parameter in ocelot.json. + /// + public const int DefaultRequestTimeoutSeconds = 90; + + private readonly ConcurrentDictionary> _handlersPool; + private readonly IDelegatingHandlerHandlerFactory _handlerFactory; + private readonly IOcelotLogger _logger; + + public MessageInvokerPool(IDelegatingHandlerHandlerFactory handlerFactory, IOcelotLoggerFactory loggerFactory) + { + _handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); + _handlersPool = new ConcurrentDictionary>(); + + ArgumentNullException.ThrowIfNull(loggerFactory); + _logger = loggerFactory.CreateLogger(); + } + + public HttpMessageInvoker Get(DownstreamRoute downstreamRoute) + { + // Since the comparison is based on the downstream route object reference, + // and the QoS Options properties can't be changed after the route is created, + // we don't need to use the timeout value as part of the cache key. + return _handlersPool.GetOrAdd( + new MessageInvokerCacheKey(downstreamRoute), + cacheKey => new Lazy(() => CreateMessageInvoker(cacheKey.DownstreamRoute)) + ).Value; + } + + public void Clear() => _handlersPool.Clear(); + + private HttpMessageInvoker CreateMessageInvoker(DownstreamRoute downstreamRoute) + { + var baseHandler = CreateHandler(downstreamRoute); + var handlers = _handlerFactory.Get(downstreamRoute).Data; + handlers.Reverse(); + + foreach (var delegatingHandler in handlers.Select(handler => handler())) + { + delegatingHandler.InnerHandler = baseHandler; + baseHandler = delegatingHandler; + } + + // Adding timeout handler to the top of the chain. + // It's standard behavior to throw TimeoutException after the defined timeout (90 seconds by default) + var timeoutHandler = new TimeoutDelegatingHandler(downstreamRoute.QosOptions.TimeoutValue == 0 + ? TimeSpan.FromSeconds(DefaultRequestTimeoutSeconds) + : TimeSpan.FromMilliseconds(downstreamRoute.QosOptions.TimeoutValue)) + { + InnerHandler = baseHandler, + }; + + return new HttpMessageInvoker(timeoutHandler, true); + } + + private HttpMessageHandler CreateHandler(DownstreamRoute downstreamRoute) + { + var handler = new SocketsHttpHandler + { + AllowAutoRedirect = downstreamRoute.HttpHandlerOptions.AllowAutoRedirect, + UseCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer, + UseProxy = downstreamRoute.HttpHandlerOptions.UseProxy, + MaxConnectionsPerServer = downstreamRoute.HttpHandlerOptions.MaxConnectionsPerServer, + PooledConnectionLifetime = downstreamRoute.HttpHandlerOptions.PooledConnectionLifeTime, + }; + + if (downstreamRoute.HttpHandlerOptions.UseCookieContainer) + { + handler.CookieContainer = new CookieContainer(); + } + + if (!downstreamRoute.DangerousAcceptAnyServerCertificateValidator) + { + return handler; + } + + handler.SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true, + }; + + _logger.LogWarning(() => + $"You have ignored all SSL warnings by using DangerousAcceptAnyServerCertificateValidator for this DownstreamRoute, UpstreamPathTemplate: {downstreamRoute.UpstreamPathTemplate}, DownstreamPathTemplate: {downstreamRoute.DownstreamPathTemplate}"); + + return handler; + } + + private readonly struct MessageInvokerCacheKey(DownstreamRoute downstreamRoute) : IEquatable + { + public DownstreamRoute DownstreamRoute { get; } = downstreamRoute; + + public override bool Equals(object obj) => obj is MessageInvokerCacheKey key && Equals(key); + + public bool Equals(MessageInvokerCacheKey other) => + EqualityComparer.Default.Equals(DownstreamRoute, other.DownstreamRoute); + + public override int GetHashCode() => DownstreamRoute.GetHashCode(); + + public static bool operator ==(MessageInvokerCacheKey left, MessageInvokerCacheKey right) => left.Equals(right); + public static bool operator !=(MessageInvokerCacheKey left, MessageInvokerCacheKey right) => !(left == right); + } +} diff --git a/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs b/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs index d996d2983..e26d6a5b3 100644 --- a/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs +++ b/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs @@ -21,10 +21,7 @@ public class HttpRequesterMiddleware : OcelotMiddleware public async Task Invoke(HttpContext httpContext) { - var downstreamRoute = httpContext.Items.DownstreamRoute(); - var response = await _requester.GetResponse(httpContext); - CreateLogBasedOnResponse(response); if (response.IsError) @@ -36,9 +33,7 @@ public async Task Invoke(HttpContext httpContext) } Logger.LogDebug("setting http response message"); - httpContext.Items.UpsertDownstreamResponse(new DownstreamResponse(response.Data)); - await _next.Invoke(httpContext); } diff --git a/src/Ocelot/Requester/ServiceCollectionExtensions.cs b/src/Ocelot/Requester/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..24d72b972 --- /dev/null +++ b/src/Ocelot/Requester/ServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Ocelot.Requester; + +public static class ServiceCollectionExtensions +{ + public static void AddOcelotMessageInvokerPool(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/Ocelot/Requester/TimeoutDelegatingHandler.cs b/src/Ocelot/Requester/TimeoutDelegatingHandler.cs new file mode 100644 index 000000000..8f4effdfb --- /dev/null +++ b/src/Ocelot/Requester/TimeoutDelegatingHandler.cs @@ -0,0 +1,31 @@ +namespace Ocelot.Requester; + +public class TimeoutDelegatingHandler : DelegatingHandler +{ + private readonly TimeSpan _timeout; + + /// + /// Initializes a new instance of the class. + /// + /// The time span after which the request is cancelled. + public TimeoutDelegatingHandler(TimeSpan timeout) + { + _timeout = timeout; + } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(_timeout); + + try + { + return await base.SendAsync(request, cts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException(); + } + } +} diff --git a/src/Ocelot/Responder/HttpContextResponder.cs b/src/Ocelot/Responder/HttpContextResponder.cs index 2c21e0c9e..326c4d95a 100644 --- a/src/Ocelot/Responder/HttpContextResponder.cs +++ b/src/Ocelot/Responder/HttpContextResponder.cs @@ -4,97 +4,93 @@ using Ocelot.Headers; using Ocelot.Middleware; -namespace Ocelot.Responder +namespace Ocelot.Responder; + +/// +/// Cannot unit test things in this class due to methods not being implemented on .NET concretes used for testing. +/// +public class HttpContextResponder : IHttpResponder { - /// - /// Cannot unit test things in this class due to methods not being implemented - /// on .net concretes used for testing. - /// - public class HttpContextResponder : IHttpResponder + private readonly IRemoveOutputHeaders _removeOutputHeaders; + + public HttpContextResponder(IRemoveOutputHeaders removeOutputHeaders) { - private readonly IRemoveOutputHeaders _removeOutputHeaders; + _removeOutputHeaders = removeOutputHeaders; + } - public HttpContextResponder(IRemoveOutputHeaders removeOutputHeaders) - { - _removeOutputHeaders = removeOutputHeaders; - } + public async Task SetResponseOnHttpContext(HttpContext context, DownstreamResponse response) + { + _removeOutputHeaders.Remove(response.Headers); - public async Task SetResponseOnHttpContext(HttpContext context, DownstreamResponse response) + foreach (var httpResponseHeader in response.Headers) { - _removeOutputHeaders.Remove(response.Headers); - - foreach (var httpResponseHeader in response.Headers) - { - AddHeaderIfDoesntExist(context, httpResponseHeader); - } - - SetStatusCode(context, (int)response.StatusCode); + AddHeaderIfDoesntExist(context, httpResponseHeader); + } - context.Response.HttpContext.Features.Get().ReasonPhrase = response.ReasonPhrase; + SetStatusCode(context, (int)response.StatusCode); - if (response.Content is null) - { - return; - } + context.Response.HttpContext.Features.Get().ReasonPhrase = response.ReasonPhrase; - foreach (var httpResponseHeader in response.Content.Headers) - { - AddHeaderIfDoesntExist(context, new Header(httpResponseHeader.Key, httpResponseHeader.Value)); - } + // As of 5.0 HttpResponse.Content never returns null. + // https://github.com/dotnet/runtime/blame/8fc68f626a11d646109a758cb0fc70a0aa7826f1/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs#L46 + // TODO: Check if it applies to ocelot custom implementation + if (response.Content is null) + { + return; + } - var content = await response.Content.ReadAsStreamAsync(); + foreach (var httpResponseHeader in response.Content.Headers) + { + AddHeaderIfDoesntExist(context, new Header(httpResponseHeader.Key, httpResponseHeader.Value)); + } - if (response.Content.Headers.ContentLength != null) - { - AddHeaderIfDoesntExist(context, new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() })); - } + if (response.Content.Headers.ContentLength != null) + { + AddHeaderIfDoesntExist(context, + new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() })); + } - await using (content) - { - if (response.StatusCode != HttpStatusCode.NotModified && context.Response.ContentLength != 0) - { - await content.CopyToAsync(context.Response.Body); - } - } + if (response.StatusCode != HttpStatusCode.NotModified && context.Response.ContentLength != 0) + { + await using var content = await response.Content.ReadAsStreamAsync(); + await content.CopyToAsync(context.Response.Body, context.RequestAborted); } + } + + public void SetErrorResponseOnContext(HttpContext context, int statusCode) + { + SetStatusCode(context, statusCode); + } - public void SetErrorResponseOnContext(HttpContext context, int statusCode) + public async Task SetErrorResponseOnContext(HttpContext context, DownstreamResponse response) + { + if (response.Content.Headers.ContentLength != null) { - SetStatusCode(context, statusCode); + AddHeaderIfDoesntExist(context, + new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() })); } - public async Task SetErrorResponseOnContext(HttpContext context, DownstreamResponse response) + if (context.Response.ContentLength != 0) { - var content = await response.Content.ReadAsStreamAsync(); - - if (response.Content.Headers.ContentLength != null) - { - AddHeaderIfDoesntExist(context, new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() })); - } - - await using (content) - { - if (context.Response.ContentLength != 0) - { - await content.CopyToAsync(context.Response.Body); - } - } + await using var content = await response.Content.ReadAsStreamAsync(); + await content.CopyToAsync(context.Response.Body, context.RequestAborted); } + } - private static void SetStatusCode(HttpContext context, int statusCode) + private static void SetStatusCode(HttpContext context, int statusCode) + { + if (!context.Response.HasStarted) { - if (!context.Response.HasStarted) - { - context.Response.StatusCode = statusCode; - } + context.Response.StatusCode = statusCode; } + } - private static void AddHeaderIfDoesntExist(HttpContext context, Header httpResponseHeader) + private static void AddHeaderIfDoesntExist(HttpContext context, Header httpResponseHeader) + { + if (!context.Response.Headers.ContainsKey(httpResponseHeader.Key)) { - if (!context.Response.Headers.ContainsKey(httpResponseHeader.Key)) - { - context.Response.Headers.Append(httpResponseHeader.Key, new StringValues(httpResponseHeader.Values.ToArray())); - } + context.Response.Headers.Append(httpResponseHeader.Key, + new StringValues(httpResponseHeader.Values.ToArray())); } } } diff --git a/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs b/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs index 76db720a1..a79a22c18 100644 --- a/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs +++ b/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs @@ -18,8 +18,7 @@ public class ResponderMiddleware : OcelotMiddleware public ResponderMiddleware(RequestDelegate next, IHttpResponder responder, IOcelotLoggerFactory loggerFactory, - IErrorsToHttpStatusCodeMapper codeMapper - ) + IErrorsToHttpStatusCodeMapper codeMapper) : base(loggerFactory.CreateLogger()) { _next = next; @@ -32,38 +31,43 @@ public async Task Invoke(HttpContext httpContext) await _next.Invoke(httpContext); var errors = httpContext.Items.Errors(); - var downstreamResponse = httpContext.Items.DownstreamResponse(); - // todo check errors is ok + // We are going to dispose the http request message and content in + // this middleware (no further use). That's why we are using the 'using' statement. + using var downstreamResponse = httpContext.Items.DownstreamResponse(); + if (errors.Count > 0) { - Logger.LogWarning(() => $"{errors.ToErrorString()} errors found in {MiddlewareName}. Setting error response for request path:{httpContext.Request.Path}, request method: {httpContext.Request.Method}"); + Logger.LogWarning(() => + $"{errors.ToErrorString()} errors found in {MiddlewareName}. Setting error response for request path:{httpContext.Request.Path}, request method: {httpContext.Request.Method}"); + await SetErrorResponse(httpContext, errors); - SetErrorResponse(httpContext, errors); + return; } - else if (downstreamResponse == null) + + if (downstreamResponse == null) { Logger.LogDebug(() => $"Pipeline was terminated early in {MiddlewareName}"); + return; } - else - { - Logger.LogDebug("no pipeline errors, setting and returning completed response"); - await _responder.SetResponseOnHttpContext(httpContext, downstreamResponse); - } + Logger.LogDebug("no pipeline errors, setting and returning completed response"); + await _responder.SetResponseOnHttpContext(httpContext, downstreamResponse); } - private void SetErrorResponse(HttpContext context, List errors) + private async Task SetErrorResponse(HttpContext context, List errors) { - //todo - refactor this all teh way down because its shit + // TODO The exception/error handling should be reviewed and refactored. var statusCode = _codeMapper.Map(errors); _responder.SetErrorResponseOnContext(context, statusCode); - if (errors.Any(e => e.Code == OcelotErrorCode.QuotaExceededError)) + if (errors.All(e => e.Code != OcelotErrorCode.QuotaExceededError)) { - var downstreamResponse = context.Items.DownstreamResponse(); - _responder.SetErrorResponseOnContext(context, downstreamResponse); + return; } + + var downstreamResponse = context.Items.DownstreamResponse(); + await _responder.SetErrorResponseOnContext(context, downstreamResponse); } } } diff --git a/test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs b/test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs deleted file mode 100644 index 108fb6456..000000000 --- a/test/Ocelot.AcceptanceTests/Caching/HttpClientCachingTests.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; -using Ocelot.Configuration.File; -using Ocelot.Requester; -using System.Collections.Concurrent; - -namespace Ocelot.AcceptanceTests.Caching -{ - public sealed class HttpClientCachingTests : IDisposable - { - private readonly Steps _steps; - private readonly ServiceHandler _serviceHandler; - private const string HelloFromLaura = "Hello from Laura"; - - public HttpClientCachingTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - } - - private FileRoute GivenRoute(int port, string template) => new() - { - DownstreamPathTemplate = template, - DownstreamScheme = Uri.UriSchemeHttp, - DownstreamHostAndPorts = - [ - new("localhost", port), - ], - UpstreamPathTemplate = template, - UpstreamHttpMethod =["Get"], - }; - - private FileConfiguration GivenFileConfiguration(params FileRoute[] routes) - { - var config = new FileConfiguration(); - config.Routes.AddRange(routes); - return config; - } - - [Fact] - public void Should_cache_one_http_client_same_route() - { - var port = PortFinder.GetRandomPort(); - var configuration = GivenFileConfiguration( - GivenRoute(port, "/")); - var cache = new FakeHttpClientCache(); - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloFromLaura)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(HelloFromLaura)) - .And(x => ThenTheCountShouldBe(cache, 1)) - .BDDfy(); - } - - [Fact] - public void Should_cache_two_http_client_different_route() - { - var port = PortFinder.GetRandomPort(); - var configuration = GivenFileConfiguration( - GivenRoute(port, "/"), - GivenRoute(port, "/two")); - var cache = new FakeHttpClientCache(); - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloFromLaura)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two")) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(HelloFromLaura)) - .And(x => ThenTheCountShouldBe(cache, 2)) - .BDDfy(); - } - - private static void ThenTheCountShouldBe(FakeHttpClientCache cache, int count) - => cache.Count.ShouldBe(count); - - private void GivenThereIsAServiceRunningOn(string baseUrl, HttpStatusCode statusCode, string responseBody) - { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, async context => - { - context.Response.StatusCode = (int)statusCode; - await context.Response.WriteAsync(responseBody); - }); - } - - public void Dispose() - { - _serviceHandler.Dispose(); - _steps.Dispose(); - } - - public class FakeHttpClientCache : IHttpClientCache - { - private readonly ConcurrentDictionary _httpClientsCache; - - public FakeHttpClientCache() - => _httpClientsCache = new ConcurrentDictionary(); - - public void Set(DownstreamRoute key, IHttpClient client, TimeSpan expirationTime) - => _httpClientsCache.AddOrUpdate(key, client, (k, oldValue) => client); - - public IHttpClient Get(DownstreamRoute key) - => _httpClientsCache.TryGetValue(key, out var client) ? client : null; - - public int Count => _httpClientsCache.Count; - } - } -} diff --git a/test/Ocelot.AcceptanceTests/ContentTests.cs b/test/Ocelot.AcceptanceTests/ContentTests.cs index 18848d719..5c64da21f 100644 --- a/test/Ocelot.AcceptanceTests/ContentTests.cs +++ b/test/Ocelot.AcceptanceTests/ContentTests.cs @@ -1,50 +1,37 @@ using Microsoft.AspNetCore.Http; using Ocelot.Configuration.File; +using System.Diagnostics; namespace Ocelot.AcceptanceTests { - public class ContentTests : IDisposable + public sealed class ContentTests : IDisposable { - private readonly Steps _steps; private string _contentType; private long? _contentLength; + private long _memoryUsageAfterCallToService; private bool _contentTypeHeaderExists; + private readonly ServiceHandler _serviceHandler; + private readonly Steps _steps; public ContentTests() { _serviceHandler = new ServiceHandler(); _steps = new Steps(); } + + public void Dispose() + { + _serviceHandler.Dispose(); + _steps.Dispose(); + } [Fact] - public void should_not_add_content_type_or_content_length_headers() + public void Should_Not_add_content_type_or_content_length_headers() { var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura")) + var configuration = GivenConfiguration(port); + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -56,35 +43,12 @@ public void should_not_add_content_type_or_content_length_headers() } [Fact] - public void should_add_content_type_and_content_length_headers() + public void Should_add_content_type_and_content_length_headers() { var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Post" }, - }, - }, - }; - + var configuration = GivenConfiguration(port, HttpMethods.Post); var contentType = "application/json"; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 201, string.Empty)) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.Created, string.Empty)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .And(x => _steps.GivenThePostHasContent("postContent")) @@ -96,33 +60,11 @@ public void should_add_content_type_and_content_length_headers() } [Fact] - public void should_add_default_content_type_header() + public void Should_add_default_content_type_header() { var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - DownstreamScheme = "http", - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Post" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 201, string.Empty)) + var configuration = GivenConfiguration(port, HttpMethods.Post); + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.Created, string.Empty)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .And(x => _steps.GivenThePostHasContent("postContent")) @@ -132,6 +74,30 @@ public void should_add_default_content_type_header() .BDDfy(); } + [Fact] + [Trait("PR", "1824")] + [Trait("Issues", "356 695 1924")] + public void Should_Not_increase_memory_usage_When_downloading_large_file() + { + var port = PortFinder.GetRandomPort(); + var configuration = GivenConfiguration(port); + var dummyDatFilePath = GenerateDummyDatFile(100); + this.Given(x => x.GivenThereIsAServiceWithPayloadRunningOn($"http://localhost:{port}", "/", dummyDatFilePath)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .Then(x => x.ThenMemoryUsageShouldNotIncrease()) + .BDDfy(); + } + + private void ThenMemoryUsageShouldNotIncrease() + { + var currentMemoryUsage = Process.GetCurrentProcess().WorkingSet64; + var tolerance = currentMemoryUsage - (10 * 1024 * 1024L); + Assert.InRange(_memoryUsageAfterCallToService, currentMemoryUsage - tolerance, currentMemoryUsage + tolerance); + } + private void ThenTheContentTypeIsIs(string expected) { _contentType.ShouldBe(expected); @@ -148,22 +114,76 @@ private void ThenTheContentTypeShouldBeEmpty() _contentTypeHeaderExists.ShouldBe(false); } - private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody) + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, HttpStatusCode statusCode, string responseBody) { _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => { _contentType = context.Request.ContentType; _contentLength = context.Request.ContentLength; _contentTypeHeaderExists = context.Request.Headers.TryGetValue("Content-Type", out var value); - context.Response.StatusCode = statusCode; + context.Response.StatusCode = (int)statusCode; await context.Response.WriteAsync(responseBody); }); } - public void Dispose() + private void GivenThereIsAServiceWithPayloadRunningOn(string baseUrl, string basePath, string dummyDatFilePath) { - _serviceHandler?.Dispose(); - _steps.Dispose(); - } + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + { + context.Response.StatusCode = (int)HttpStatusCode.OK; + await using var fileStream = File.OpenRead(dummyDatFilePath); + await fileStream.CopyToAsync(context.Response.Body); + _memoryUsageAfterCallToService = Process.GetCurrentProcess().WorkingSet64; + }); + } + + /// + /// Generates a dummy payload of the given size in MB. + /// Avoiding maintaining a large file in the repository. + /// + /// The file size in MB. + /// The payload file path. + /// Throwing an exception if the payload path is null. + private static string GenerateDummyDatFile(int sizeInMb) + { + var payloadName = "dummy.dat"; + var payloadPath = Path.Combine(Directory.GetCurrentDirectory(), payloadName); + + if (File.Exists(payloadPath)) + { + File.Delete(payloadPath); + } + + var newFile = new FileStream(payloadPath, FileMode.CreateNew); + try + { + newFile.Seek(sizeInMb * 1024L * 1024, SeekOrigin.Begin); + newFile.WriteByte(0); + } + finally + { + newFile.Dispose(); + } + + return payloadPath; + } + + private static FileConfiguration GivenConfiguration(int port, string method = null) => new() + { + Routes = + [ + new FileRoute + { + DownstreamPathTemplate = "/", + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = + [ + new FileHostAndPort("localhost", port), + ], + UpstreamPathTemplate = "/", + UpstreamHttpMethod = [method ?? HttpMethods.Get], + }, + ], + }; } } diff --git a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs index 0e6c32333..2df77bc29 100644 --- a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs +++ b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs @@ -7,14 +7,14 @@ namespace Ocelot.AcceptanceTests public class PollyQoSTests : IDisposable { private readonly Steps _steps; - private readonly ServiceHandler _serviceHandler; - + private readonly ServiceHandler _serviceHandler; + public PollyQoSTests() { _serviceHandler = new ServiceHandler(); _steps = new Steps(); } - + private static FileConfiguration FileConfigurationFactory(int port, QoSOptions options, string httpMethod = nameof(HttpMethods.Get)) => new() { @@ -34,7 +34,7 @@ private static FileConfiguration FileConfigurationFactory(int port, QoSOptions o }, }, }; - + [Fact] public void Should_not_timeout() { @@ -49,13 +49,13 @@ public void Should_not_timeout() .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) .BDDfy(); } - + [Fact] public void Should_timeout() { var port = PortFinder.GetRandomPort(); var configuration = FileConfigurationFactory(port, new QoSOptions(0, 0, 10, null), HttpMethods.Post); - + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 1000)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithPolly()) @@ -64,13 +64,13 @@ public void Should_timeout() .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) .BDDfy(); } - + [Fact] public void Should_open_circuit_breaker_after_two_exceptions() { var port = PortFinder.GetRandomPort(); var configuration = FileConfigurationFactory(port, new QoSOptions(2, 5000, 100000, null)); - + this.Given(x => x.GivenThereIsABrokenServiceRunningOn($"http://localhost:{port}")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithPolly()) @@ -86,7 +86,7 @@ public void Should_open_circuit_breaker_then_close() { var port = PortFinder.GetRandomPort(); var configuration = FileConfigurationFactory(port, new QoSOptions(1, 500, 1000, null)); - + this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port}", "Hello from Laura")) .Given(x => _steps.GivenThereIsAConfiguration(configuration)) .Given(x => _steps.GivenOcelotIsRunningWithPolly()) @@ -105,23 +105,23 @@ public void Should_open_circuit_breaker_then_close() .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } - + [Fact] public void Open_circuit_should_not_effect_different_route() { var port1 = PortFinder.GetRandomPort(); var port2 = PortFinder.GetRandomPort(); - var qos1 = new QoSOptions(1, 1000, 500, null); - + var qos1 = new QoSOptions(1, 1000, 1000, null); + var configuration = FileConfigurationFactory(port1, qos1); var route2 = configuration.Routes[0].Clone() as FileRoute; route2.DownstreamHostAndPorts[0].Port = port2; route2.UpstreamPathTemplate = "/working"; route2.QoSOptions = new(); configuration.Routes.Add(route2); - + this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port1}", "Hello from Laura")) - .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port2}/", 200, "Hello from Tom", 0)) + .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port2}", 200, "Hello from Tom", 0)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithPolly()) .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -142,14 +142,14 @@ public void Open_circuit_should_not_effect_different_route() .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } - + [Fact(DisplayName = "1833: " + nameof(Should_timeout_per_default_after_90_seconds))] public void Should_timeout_per_default_after_90_seconds() { var port = PortFinder.GetRandomPort(); var configuration = FileConfigurationFactory(port, new QoSOptions(new FileQoSOptions()), HttpMethods.Get); - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 95000)) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, string.Empty, 95000)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithPolly()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -182,8 +182,8 @@ private void GivenThereIsAPossiblyBrokenServiceRunningOn(string url, string resp context.Response.StatusCode = 200; await context.Response.WriteAsync(responseBody); }); - } - + } + private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody, int timeout) { _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => @@ -194,11 +194,11 @@ private void GivenThereIsAServiceRunningOn(string url, int statusCode, string re }); } - public void Dispose() - { - _serviceHandler?.Dispose(); + public void Dispose() + { + _serviceHandler?.Dispose(); _steps.Dispose(); - GC.SuppressFinalize(this); - } - } + GC.SuppressFinalize(this); + } + } } diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs index d87391b32..bf6274c84 100644 --- a/test/Ocelot.AcceptanceTests/Steps.cs +++ b/test/Ocelot.AcceptanceTests/Steps.cs @@ -22,7 +22,6 @@ using Ocelot.Provider.Consul; using Ocelot.Provider.Eureka; using Ocelot.Provider.Polly; -using Ocelot.Requester; using Ocelot.ServiceDiscovery.Providers; using Ocelot.Tracing.Butterfly; using Ocelot.Tracing.OpenTracing; @@ -445,36 +444,7 @@ public void GivenOcelotIsRunningUsingJsonSerializedCache() _ocelotClient = _ocelotServer.CreateClient(); } - public void GivenOcelotIsRunningWithFakeHttpClientCache(IHttpClientCache cache) - { - _webHostBuilder = new WebHostBuilder(); - - _webHostBuilder - .ConfigureAppConfiguration((hostingContext, config) => - { - config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath); - var env = hostingContext.HostingEnvironment; - config.AddJsonFile("appsettings.json", true, false) - .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true, false); - config.AddJsonFile(_ocelotConfigFileName, false, false); - config.AddEnvironmentVariables(); - }) - .ConfigureServices(s => - { - s.AddSingleton(cache); - s.AddOcelot(); - }) - .Configure(app => { app.UseOcelot().Wait(); }); - - _ocelotServer = new TestServer(_webHostBuilder); - - _ocelotClient = _ocelotServer.CreateClient(); - } - - internal void GivenIWait(int wait) - { - Thread.Sleep(wait); - } + internal void GivenIWait(int wait) => Thread.Sleep(wait); public void GivenOcelotIsRunningWithMiddlewareBeforePipeline(Func callback) { diff --git a/test/Ocelot.Benchmarks/DictionaryBenchmarks.cs b/test/Ocelot.Benchmarks/DictionaryBenchmarks.cs deleted file mode 100644 index 09c68ab1c..000000000 --- a/test/Ocelot.Benchmarks/DictionaryBenchmarks.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Ocelot.Configuration; -using Ocelot.Configuration.Builder; -using Ocelot.Requester; -using System.Collections.Concurrent; - -namespace Ocelot.Benchmarks -{ - [Config(typeof(DictionaryBenchmarks))] - public class DictionaryBenchmarks : ManualConfig - { - private ConcurrentDictionary _downstreamRouteDictionary; - private ConcurrentDictionary _stringRouteDictionary; - private HttpClientWrapper _client; - private string _stringKey; - private DownstreamRoute _downstreamRouteKey; - - public DictionaryBenchmarks() - { - AddColumn(StatisticColumn.AllStatistics); - AddDiagnoser(MemoryDiagnoser.Default); - AddValidator(BaselineValidator.FailOnError); - } - - [GlobalSetup] - public void SetUp() - { - _downstreamRouteKey = new DownstreamRouteBuilder().Build(); - _stringKey = "test"; - _client = new HttpClientWrapper(new HttpClient()); - _downstreamRouteDictionary = new ConcurrentDictionary(); - - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - _downstreamRouteDictionary.TryAdd(new DownstreamRouteBuilder().Build(), new HttpClientWrapper(new HttpClient())); - - _stringRouteDictionary = new ConcurrentDictionary(); - _stringRouteDictionary.TryAdd("1", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("2", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("3", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("4", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("5", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("6", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("7", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("8", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("9", new HttpClientWrapper(new HttpClient())); - _stringRouteDictionary.TryAdd("10", new HttpClientWrapper(new HttpClient())); - } - - [Benchmark(Baseline = true)] - public IHttpClient StringKey() - { - _stringRouteDictionary.AddOrUpdate(_stringKey, _client, (k, oldValue) => _client); - return _stringRouteDictionary.TryGetValue(_stringKey, out var client) ? client : null; - } - - [Benchmark] - public IHttpClient DownstreamRouteKey() - { - _downstreamRouteDictionary.AddOrUpdate(_downstreamRouteKey, _client, (k, oldValue) => _client); - return _downstreamRouteDictionary.TryGetValue(_downstreamRouteKey, out var client) ? client : null; - } - } -} diff --git a/test/Ocelot.Benchmarks/Program.cs b/test/Ocelot.Benchmarks/Program.cs index 090e60aaa..bea66ff17 100644 --- a/test/Ocelot.Benchmarks/Program.cs +++ b/test/Ocelot.Benchmarks/Program.cs @@ -9,7 +9,6 @@ public static void Main(string[] args) var switcher = new BenchmarkSwitcher( new[] { - typeof(DictionaryBenchmarks), typeof(UrlPathToUrlPathTemplateMatcherBenchmarks), typeof(AllTheThingsBenchmarks), typeof(ExceptionHandlerMiddlewareBenchmarks), @@ -17,6 +16,7 @@ public static void Main(string[] args) typeof(SerilogBenchmarks), typeof(MsLoggerBenchmarks), typeof(PayloadBenchmarks), + typeof(ResponseBenchmarks), }); switcher.Run(args); } diff --git a/test/Ocelot.Benchmarks/ResponseBenchmarks.cs b/test/Ocelot.Benchmarks/ResponseBenchmarks.cs new file mode 100644 index 000000000..f68ecb254 --- /dev/null +++ b/test/Ocelot.Benchmarks/ResponseBenchmarks.cs @@ -0,0 +1,258 @@ +using BenchmarkDotNet.Order; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; +using Ocelot.Responses; + +namespace Ocelot.Benchmarks; + +[Config(typeof(ResponseBenchmarks))] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class ResponseBenchmarks : ManualConfig +{ + private IWebHost _service; + private IWebHost _ocelot; + private HttpClient _httpClient; + private string _currentPayloadPath; + private bool _currentIsJson; + + private const string BasePayload = + "{\"_id\":\"65789c1611a3b1feb49f9e65\",\"index\":0,\"guid\":\"6622d724-c17d-4939-9c68-158bf2dc5c57\",\"isActive\":false,\"balance\":\"$1,398.26\",\"picture\":\"http://placehold.it/32x32\",\"age\":33,\"eyeColor\":\"blue\",\"name\":\"WilkersonPayne\",\"gender\":\"male\",\"company\":\"NEOCENT\",\"email\":\"wilkersonpayne@neocent.com\",\"phone\":\"+1(837)588-3248\",\"address\":\"932BatchelderStreet,Campo,Texas,1310\",\"about\":\"Dolorsuntminimnullatemporlaboretempornostrudnon.Irureconsectetursintenimestadduissunttemporquisnisi.Laboreoccaecatculpaaliquaipsumreprehenderitadofficia.Sunteuutinpariaturanimofficia.CommodosintLoremametincididuntvelitesse.Nonaliquasintdoeiusmodexercitation.Suntcommododolorcupidatatculpareprehenderitfugiatexquisamet.\\r\\n\",\"registered\":\"2021-09-06T11:54:41-02:00\",\"latitude\":-45.256336,\"longitude\":164.343713,\"tags\":[\"cillum\",\"cupidatat\",\"aliquip\",\"culpa\",\"non\",\"laboris\",\"non\"],\"friends\":[{\"id\":0,\"name\":\"MistyMorton\"},{\"id\":1,\"name\":\"AraceliAcosta\"},{\"id\":2,\"name\":\"WalterDelaney\"}],\"greeting\":\"Hello,WilkersonPayne!Youhave1unreadmessages.\",\"favoriteFruit\":\"strawberry\"}"; + + public ResponseBenchmarks() + { + AddColumn(StatisticColumn.AllStatistics); + AddDiagnoser(MemoryDiagnoser.Default); + AddValidator(BaselineValidator.FailOnError); + } + + [GlobalSetup] + public void SetUp() + { + var configuration = new FileConfiguration + { + Routes = + [ + new FileRoute + { + DownstreamPathTemplate = "/", + DownstreamHostAndPorts = + [ + new FileHostAndPort("localhost", 51879), + ], + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod =["GET"], + }, + ], + }; + + GivenThereIsAServiceRunningOn("http://localhost:51879", "/", 201); + GivenThereIsAConfiguration(configuration); + GivenOcelotIsRunning("http://localhost:5000"); + + _httpClient = new HttpClient(); + } + + [Benchmark(Baseline = true)] + [ArgumentsSource(nameof(Payloads))] + public async Task Baseline(string payLoadPath, string payloadName, bool isJson) + { + _currentPayloadPath = payLoadPath; + _currentIsJson = isJson; + + var response = await _httpClient.GetAsync("http://localhost:5000/"); + response.EnsureSuccessStatusCode(); + } + + /// + /// Generating the payloads for the benchmarks dynamically. + /// + /// The payloads containing path, file name and a boolean indicating if the file is a json or not. + public static IEnumerable Payloads() + { + var baseDirectory = GetBaseDirectory(); + var payloadsDirectory = Path.Combine(baseDirectory, nameof(Payloads)); + + if (!Directory.Exists(payloadsDirectory)) + { + Directory.CreateDirectory(payloadsDirectory); + } + + // Array of sizes in kilobytes for JSON files + var jsonSizes = new[] { 1, 16, 32, 64, 128, 256, 512, 2 * 1024, 8 * 1024, 15 * 1024, 30 * 1024 }; + foreach (var size in jsonSizes) + { + yield return GeneratePayload(size, payloadsDirectory, $"{size}KBPayload.json", true); + } + + // Array of sizes in megabytes for DAT files + var datSizes = new[] { 10, 100, 1024 }; + foreach (var size in datSizes) + { + yield return GeneratePayload(size, payloadsDirectory, $"{size}MBPayload.dat", false); + } + } + + private static string GetBaseDirectory() + { + var baseDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + Debug.Assert(baseDirectory != null, nameof(baseDirectory) + " != null"); + return baseDirectory; + } + + private static object[] GeneratePayload(int size, string directory, string fileName, bool isJson) + { + var filePath = Path.Combine(directory, fileName); + var generateDummy = isJson ? (Func)GenerateDummyJsonFile : GenerateDummyDatFile; + return + [ + generateDummy(size, filePath), + fileName, + isJson, + ]; + } + + /// + /// Generates a dummy payload of the given size in KB. + /// The payload is a JSON array of the given size. + /// + /// The size in KB. + /// The payload path. + /// The current payload path. + private static string GenerateDummyJsonFile(int sizeInKb, string payloadPath) + { + ArgumentNullException.ThrowIfNull(payloadPath); + + if (File.Exists(payloadPath)) + { + return payloadPath; + } + + var targetSizeInBytes = sizeInKb * 1024L; + + using var fileStream = new FileStream(payloadPath, FileMode.Create, FileAccess.Write); + using var streamWriter = new StreamWriter(fileStream); + + var byteArrayLength = Encoding.UTF8.GetBytes(BasePayload).Length; + var firstObject = true; + + streamWriter.Write("["); + while (fileStream.Length < targetSizeInBytes - byteArrayLength) + { + if (!firstObject) + { + streamWriter.Write(","); + } + else + { + firstObject = false; + } + + streamWriter.Write(BasePayload); + } + + streamWriter.Write("]"); + + return payloadPath; + } + + /// + /// Generates a dummy payload of the given size in MB. + /// Avoiding maintaining a large file in the repository. + /// + /// The file size in MB. + /// The path to the payload file. + /// The payload file path. + /// Throwing an exception if the payload path is null. + private static string GenerateDummyDatFile(int sizeInMb, string payloadPath) + { + ArgumentNullException.ThrowIfNull(payloadPath); + + if (File.Exists(payloadPath)) + { + return payloadPath; + } + + using var newFile = new FileStream(payloadPath, FileMode.CreateNew); + newFile.Seek(sizeInMb * 1024L * 1024, SeekOrigin.Begin); + newFile.WriteByte(0); + newFile.Close(); + + return payloadPath; + } + + private void GivenOcelotIsRunning(string url) + { + _ocelot = new WebHostBuilder() + .UseKestrel() + .UseUrls(url) + .UseContentRoot(Directory.GetCurrentDirectory()) + .ConfigureAppConfiguration((hostingContext, config) => + { + config + .SetBasePath(hostingContext.HostingEnvironment.ContentRootPath) + .AddJsonFile("appsettings.json", true, true) + .AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", true, true) + .AddJsonFile(ConfigurationBuilderExtensions.PrimaryConfigFile, false, false) + .AddEnvironmentVariables(); + }) + .ConfigureKestrel((_, hostingOptions) => { hostingOptions.Limits.MaxRequestBodySize = 2684354561; }) + .ConfigureServices(s => { s.AddOcelot(); }) + .ConfigureLogging((hostingContext, logging) => + { + logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging")); + }) + .Configure(app => { app.UseOcelot().Wait(); }) + .Build(); + + _ocelot.Start(); + } + + public static void GivenThereIsAConfiguration(FileConfiguration fileConfiguration) + { + var configurationPath = Path.Combine(AppContext.BaseDirectory, ConfigurationBuilderExtensions.PrimaryConfigFile); + var jsonConfiguration = JsonConvert.SerializeObject(fileConfiguration); + + if (File.Exists(configurationPath)) + { + File.Delete(configurationPath); + } + + File.WriteAllText(configurationPath, jsonConfiguration); + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode) + { + _service = new WebHostBuilder() + .UseUrls(baseUrl) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .ConfigureKestrel((_, hostingOptions) => { hostingOptions.Limits.MaxRequestBodySize = 2684354561; }) + .Configure(app => + { + app.UsePathBase(basePath); + app.Run(async context => + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = string.Concat("application/", _currentIsJson ? "json" : "octet-stream"); + using var content = new StreamContent(File.OpenRead(_currentPayloadPath)); + content.Headers.ContentType = new MediaTypeHeaderValue(string.Concat("application/", _currentIsJson ? "json" : "octet-stream")); + await content.CopyToAsync(context.Response.Body); + }); + }) + .Build(); + + _service.Start(); + } +} diff --git a/test/Ocelot.IntegrationTests/AdministrationTests.cs b/test/Ocelot.IntegrationTests/AdministrationTests.cs index 34298a03a..3a57d0d9d 100644 --- a/test/Ocelot.IntegrationTests/AdministrationTests.cs +++ b/test/Ocelot.IntegrationTests/AdministrationTests.cs @@ -882,7 +882,7 @@ private void ThenTheStatusCodeShouldBe(HttpStatusCode expectedHttpStatusCode) private void ThenTheResultHaveMultiLineIndentedJson() { const string indent = " "; - const int total = 45, skip = 1; + const int total = 46, skip = 1; var lines = _response.Content.ReadAsStringAsync().Result.Split(Environment.NewLine); lines.Length.ShouldBe(total); lines.First().ShouldNotStartWith(indent); diff --git a/test/Ocelot.UnitTests/Configuration/HttpHandlerOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/HttpHandlerOptionsCreatorTests.cs index 02ca2d676..8e41eec9e 100644 --- a/test/Ocelot.UnitTests/Configuration/HttpHandlerOptionsCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/HttpHandlerOptionsCreatorTests.cs @@ -33,7 +33,7 @@ public void should_not_use_tracing_if_fake_tracer_registered() }, }; - var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -52,7 +52,7 @@ public void should_use_tracing_if_real_tracer_registered() }, }; - var expectedOptions = new HttpHandlerOptions(false, false, true, true, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, true, true, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .And(x => GivenARealTracer()) @@ -65,7 +65,7 @@ public void should_use_tracing_if_real_tracer_registered() public void should_create_options_with_useCookie_false_and_allowAutoRedirect_true_as_default() { var fileRoute = new FileRoute(); - var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -86,7 +86,7 @@ public void should_create_options_with_specified_useCookie_and_allowAutoRedirect }, }; - var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -102,7 +102,7 @@ public void should_create_options_with_useproxy_true_as_default() HttpHandlerOptions = new FileHttpHandlerOptions(), }; - var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -121,7 +121,7 @@ public void should_create_options_with_specified_useproxy() }, }; - var expectedOptions = new HttpHandlerOptions(false, false, false, false, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, false, false, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -140,7 +140,7 @@ public void should_create_options_with_specified_MaxConnectionsPerServer() }, }; - var expectedOptions = new HttpHandlerOptions(false, false, false, true, 10); + var expectedOptions = new HttpHandlerOptions(false, false, false, true, 10, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -159,7 +159,7 @@ public void should_create_options_fixing_specified_MaxConnectionsPerServer_range }, }; - var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -178,7 +178,7 @@ public void should_create_options_fixing_specified_MaxConnectionsPerServer_range }, }; - var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue); + var expectedOptions = new HttpHandlerOptions(false, false, false, true, int.MaxValue, DefaultPooledConnectionLifeTime); this.Given(x => GivenTheFollowing(fileRoute)) .When(x => WhenICreateHttpHandlerOptions()) @@ -212,7 +212,12 @@ private void GivenARealTracer() _serviceCollection.AddSingleton(); _serviceProvider = _serviceCollection.BuildServiceProvider(); _httpHandlerOptionsCreator = new HttpHandlerOptionsCreator(_serviceProvider); - } + } + + /// + /// 120 seconds. + /// + private static TimeSpan DefaultPooledConnectionLifeTime => TimeSpan.FromSeconds(HttpHandlerOptionsCreator.DefaultPooledConnectionLifetimeSeconds); private class FakeTracer : ITracer { diff --git a/test/Ocelot.UnitTests/Requester/DelegatingHandlerHandlerProviderFactoryTests.cs b/test/Ocelot.UnitTests/Requester/DelegatingHandlerHandlerProviderFactoryTests.cs index d6f63f3b9..a6c74c115 100644 --- a/test/Ocelot.UnitTests/Requester/DelegatingHandlerHandlerProviderFactoryTests.cs +++ b/test/Ocelot.UnitTests/Requester/DelegatingHandlerHandlerProviderFactoryTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Ocelot.Configuration; -using Ocelot.Configuration.Builder; +using Ocelot.Configuration.Builder; +using Ocelot.Configuration.Creator; using Ocelot.Logging; using Ocelot.Requester; using Ocelot.Requester.QoS; @@ -45,7 +46,7 @@ public void should_follow_ordering_add_specifics() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithDelegatingHandlers(new List { "FakeDelegatingHandler", @@ -81,7 +82,7 @@ public void should_follow_ordering_order_specifics_and_globals() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithDelegatingHandlers(new List { "FakeDelegatingHandlerTwo", @@ -118,7 +119,7 @@ public void should_follow_ordering_order_specifics() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithDelegatingHandlers(new List { "FakeDelegatingHandlerTwo", @@ -154,7 +155,7 @@ public void should_follow_ordering_order_and_only_add_specifics_in_config() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithDelegatingHandlers(new List { "FakeDelegatingHandler", @@ -188,7 +189,7 @@ public void should_follow_ordering_dont_add_specifics() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithLoadBalancerKey(string.Empty) .Build(); @@ -214,7 +215,7 @@ public void should_apply_re_route_specific() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithDelegatingHandlers(new List { "FakeDelegatingHandler", @@ -242,7 +243,9 @@ public void should_all_from_all_routes_provider_and_qos() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue)).WithLoadBalancerKey(string.Empty).Build(); + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue, DefaultPooledConnectionLifeTime)) + .WithLoadBalancerKey(string.Empty) + .Build(); this.Given(x => GivenTheFollowingRequest(route)) .And(x => GivenTheQosFactoryReturns(new FakeQoSHandler())) @@ -262,7 +265,9 @@ public void should_return_provider_with_no_delegates() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue)).WithLoadBalancerKey(string.Empty).Build(); + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue, DefaultPooledConnectionLifeTime)) + .WithLoadBalancerKey(string.Empty) + .Build(); this.Given(x => GivenTheFollowingRequest(route)) .And(x => GivenTheServiceProviderReturnsNothing()) @@ -282,7 +287,9 @@ public void should_return_provider_with_qos_delegate() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue)).WithLoadBalancerKey(string.Empty).Build(); + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue, DefaultPooledConnectionLifeTime)) + .WithLoadBalancerKey(string.Empty) + .Build(); this.Given(x => GivenTheFollowingRequest(route)) .And(x => GivenTheQosFactoryReturns(new FakeQoSHandler())) @@ -302,7 +309,9 @@ public void should_return_provider_with_qos_delegate_when_timeout_value_set() var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue)).WithLoadBalancerKey(string.Empty).Build(); + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, false, true, int.MaxValue, DefaultPooledConnectionLifeTime)) + .WithLoadBalancerKey(string.Empty) + .Build(); this.Given(x => GivenTheFollowingRequest(route)) .And(x => GivenTheQosFactoryReturns(new FakeQoSHandler())) @@ -324,7 +333,7 @@ public void should_log_error_and_return_no_qos_provider_delegate_when_qos_factor var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithLoadBalancerKey(string.Empty) .Build(); @@ -354,7 +363,7 @@ public void should_log_error_and_return_no_qos_provider_delegate_when_qos_factor var route = new DownstreamRouteBuilder() .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue)) + .WithHttpHandlerOptions(new HttpHandlerOptions(true, true, true, true, int.MaxValue, DefaultPooledConnectionLifeTime)) .WithLoadBalancerKey(string.Empty) .Build(); @@ -493,6 +502,11 @@ private void ThenNoDelegatesAreInTheProvider() _result.ShouldNotBeNull(); _result.Data.Count.ShouldBe(0); } + + /// + /// 120 seconds. + /// + private static TimeSpan DefaultPooledConnectionLifeTime => TimeSpan.FromSeconds(HttpHandlerOptionsCreator.DefaultPooledConnectionLifetimeSeconds); } internal class FakeTracingHandler : DelegatingHandler, ITracingHandler diff --git a/test/Ocelot.UnitTests/Requester/HttpClientBuilderTests.cs b/test/Ocelot.UnitTests/Requester/HttpClientBuilderTests.cs deleted file mode 100644 index a34b8af19..000000000 --- a/test/Ocelot.UnitTests/Requester/HttpClientBuilderTests.cs +++ /dev/null @@ -1,462 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; -using Ocelot.Configuration.Builder; -using Ocelot.Logging; -using Ocelot.Middleware; -using Ocelot.Request.Middleware; -using Ocelot.Requester; -using Ocelot.Responses; - -namespace Ocelot.UnitTests.Requester -{ - public sealed class HttpClientBuilderTests : IDisposable - { - private HttpClientBuilder _builder; - private readonly Mock _factory; - private IHttpClient _httpClient; - private HttpResponseMessage _response; - private HttpContext _context; - private readonly Mock _cacheHandlers; - private readonly Mock _logger; - private int _count; - private IWebHost _host; - private IHttpClient _againHttpClient; - private IHttpClient _firstHttpClient; - private MemoryHttpClientCache _realCache; - - public HttpClientBuilderTests() - { - _cacheHandlers = new Mock(); - _logger = new Mock(); - _factory = new Mock(); - _builder = new HttpClientBuilder(_factory.Object, _cacheHandlers.Object, _logger.Object); - } - - [Fact] - public void should_build_http_client() - { - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - this.Given(x => GivenTheFactoryReturns()) - .And(x => GivenARequest(route)) - .When(x => WhenIBuild()) - .Then(x => ThenTheHttpClientShouldNotBeNull()) - .BDDfy(); - } - - [Fact] - public void should_get_from_cache() - { - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - this.Given(x => GivenARealCache()) - .And(x => GivenTheFactoryReturns()) - .And(x => GivenARequest(route)) - .And(x => WhenIBuildTheFirstTime()) - .And(x => WhenISave()) - .And(x => WhenIBuildAgain()) - .And(x => WhenISave()) - .When(x => WhenIBuildAgain()) - .Then(x => ThenTheHttpClientIsFromTheCache()) - .BDDfy(); - } - - [Fact] - public void should_get_from_cache_with_different_query_string() - { - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - this.Given(x => GivenARealCache()) - .And(x => GivenTheFactoryReturns()) - .And(x => GivenARequest(route, "http://wwww.someawesomewebsite.com/woot?badman=1")) - .And(x => WhenIBuildTheFirstTime()) - .And(x => WhenISave()) - .And(x => WhenIBuildAgain()) - .And(x => GivenARequest(route, "http://wwww.someawesomewebsite.com/woot?badman=2")) - .And(x => WhenISave()) - .When(x => WhenIBuildAgain()) - .Then(x => ThenTheHttpClientIsFromTheCache()) - .BDDfy(); - } - - [Fact] - public void should_not_get_from_cache_with_different_query_string() - { - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var routeA = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithContainsQueryString(true).WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - var routeB = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithContainsQueryString(true).WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - this.Given(x => GivenARealCache()) - .And(x => GivenTheFactoryReturns()) - .And(x => GivenARequest(routeA, "http://wwww.someawesomewebsite.com/woot?badman=1")) - .And(x => WhenIBuildTheFirstTime()) - .And(x => WhenISave()) - .And(x => WhenIBuildAgain()) - .And(x => GivenARequest(routeB, "http://wwww.someawesomewebsite.com/woot?badman=2")) - .And(x => WhenISave()) - .When(x => WhenIBuildAgain()) - .Then(x => ThenTheHttpClientIsNotFromTheCache()) - .BDDfy(); - } - - [Fact] - public void should_log_if_ignoring_ssl_errors() - { - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .WithDangerousAcceptAnyServerCertificateValidator(true) - .Build(); - - this.Given(x => GivenTheFactoryReturns()) - .And(x => GivenARequest(route)) - .When(x => WhenIBuild()) - .Then(x => ThenTheHttpClientShouldNotBeNull()) - .Then(x => ThenTheDangerousAcceptAnyServerCertificateValidatorWarningIsLogged()) - .BDDfy(); - } - - [Fact] - public void should_call_delegating_handlers_in_order() - { - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - var fakeOne = new FakeDelegatingHandler(); - var fakeTwo = new FakeDelegatingHandler(); - - var handlers = new List> - { - () => fakeOne, - () => fakeTwo, - }; - - this.Given(x => GivenTheFactoryReturns(handlers)) - .And(x => GivenARequest(route)) - .And(x => WhenIBuild()) - .When(x => WhenICallTheClient()) - .Then(x => ThenTheFakeAreHandledInOrder(fakeOne, fakeTwo)) - .And(x => ThenSomethingIsReturned()) - .BDDfy(); - } - - [Fact] - public void should_re_use_cookies_from_container() - { - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, true, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - this.Given(_ => GivenADownstreamService()) - .And(_ => GivenARequest(route)) - .And(_ => GivenTheFactoryReturnsNothing()) - .And(_ => WhenIBuild()) - .And(_ => WhenICallTheClient("http://localhost:5003")) - .And(_ => ThenTheCookieIsSet()) - .And(_ => GivenTheClientIsCached()) - .And(_ => WhenIBuild()) - .When(_ => WhenICallTheClient("http://localhost:5003")) - .Then(_ => ThenTheResponseIsOk()) - .BDDfy(); - } - - [Theory] - [InlineData("GET")] - [InlineData("POST")] - [InlineData("PUT")] - [InlineData("DELETE")] - [InlineData("PATCH")] - public void should_add_verb_to_cache_key(string verb) - { - var downstreamUrl = "http://localhost:5012/"; - - var method = new HttpMethod(verb); - - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - this.Given(_ => GivenADownstreamService()) - .And(_ => GivenARequestWithAUrlAndMethod(route, downstreamUrl, method)) - .And(_ => GivenTheFactoryReturnsNothing()) - .And(_ => WhenIBuild()) - .And(_ => GivenCacheIsCalledWithExpectedKey($"{method}:{downstreamUrl}")) - .BDDfy(); - } - - [Theory(DisplayName = "1833: " + nameof(Create_TimeoutValueInQosOptions_HttpClientTimeout))] - [InlineData(0, 90)] // default timeout is 90 seconds - [InlineData(20, 20)] // QoS timeout - public void Create_TimeoutValueInQosOptions_HttpClientTimeout(int qosTimeout, int expectedSeconds) - { - // Arrange - var qosOptions = new QoSOptionsBuilder() - .WithTimeoutValue(qosTimeout * 1000) - .Build(); - var handlerOptions = new HttpHandlerOptionsBuilder() - .WithUseMaxConnectionPerServer(int.MaxValue) - .Build(); - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(handlerOptions) - .Build(); - GivenTheFactoryReturnsNothing(); - - // Act - var actualClient = _builder.Create(route); - - // Assert - var actual = actualClient?.Client?.Timeout; - Assert.NotNull(actual); - Assert.Equal(expectedSeconds, actual.Value.TotalSeconds); - } - - private void GivenARealCache() - { - _realCache = new MemoryHttpClientCache(); - _builder = new HttpClientBuilder(_factory.Object, _realCache, _logger.Object); - } - - private void ThenTheHttpClientIsFromTheCache() - { - _againHttpClient.ShouldBe(_firstHttpClient); - } - - private void ThenTheHttpClientIsNotFromTheCache() - { - _againHttpClient.ShouldNotBe(_firstHttpClient); - } - - private void WhenISave() - { - _builder.Save(); - } - - private void GivenCacheIsCalledWithExpectedKey(string expectedKey) - { - _cacheHandlers.Verify(x => x.Get(It.IsAny()), Times.Once); - } - - private void ThenTheDangerousAcceptAnyServerCertificateValidatorWarningIsLogged() - { - _logger.Verify(x => x.LogWarning(It.Is>(y => y.Invoke() == $"You have ignored all SSL warnings by using DangerousAcceptAnyServerCertificateValidator for this DownstreamRoute, UpstreamPathTemplate: {_context.Items.DownstreamRoute().UpstreamPathTemplate}, DownstreamPathTemplate: {_context.Items.DownstreamRoute().DownstreamPathTemplate}")), Times.Once); - } - - private void GivenTheClientIsCached() - { - _cacheHandlers.Setup(x => x.Get(It.IsAny())).Returns(_httpClient); - } - - private void ThenTheCookieIsSet() - { - _response.Headers.TryGetValues("Set-Cookie", out var test).ShouldBeTrue(); - } - - private void WhenICallTheClient(string url) - { - _response = _httpClient - .SendAsync(new HttpRequestMessage(HttpMethod.Get, url)) - .GetAwaiter() - .GetResult(); - } - - private void ThenTheResponseIsOk() - { - _response.StatusCode.ShouldBe(HttpStatusCode.OK); - } - - private void GivenADownstreamService() - { - _host = new WebHostBuilder() - .UseUrls("http://localhost:5003") - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseIISIntegration() - .Configure(app => - { - app.Run(context => - { - if (_count == 0) - { - context.Response.Cookies.Append("test", "0"); - context.Response.StatusCode = 200; - _count++; - return Task.CompletedTask; - } - - if (_count == 1) - { - if (context.Request.Cookies.TryGetValue("test", out var cookieValue) || context.Request.Headers.TryGetValue("Set-Cookie", out var headerValue)) - { - context.Response.StatusCode = 200; - return Task.CompletedTask; - } - - context.Response.StatusCode = 500; - } - - return Task.CompletedTask; - }); - }) - .Build(); - - _host.Start(); - } - - private void GivenARequest(DownstreamRoute downstream) - { - GivenARequest(downstream, "http://localhost:5003"); - } - - private void GivenARequest(DownstreamRoute downstream, string downstreamUrl) - { - GivenARequestWithAUrlAndMethod(downstream, downstreamUrl, HttpMethod.Get); - } - - private void GivenARequestWithAUrlAndMethod(DownstreamRoute downstream, string url, HttpMethod method) - { - _context = new DefaultHttpContext(); - _context.Items.UpsertDownstreamRoute(downstream); - _context.Items.UpsertDownstreamRequest(new DownstreamRequest(new HttpRequestMessage { RequestUri = new Uri(url), Method = method })); - } - - private void ThenSomethingIsReturned() - { - _response.ShouldNotBeNull(); - } - - private void WhenICallTheClient() - { - _response = _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")).GetAwaiter().GetResult(); - } - - private static void ThenTheFakeAreHandledInOrder(FakeDelegatingHandler fakeOne, FakeDelegatingHandler fakeTwo) - { - fakeOne.TimeCalled.ShouldBeGreaterThan(fakeTwo.TimeCalled); - } - - private void GivenTheFactoryReturns() - { - var handlers = new List> { () => new FakeDelegatingHandler() }; - - _factory - .Setup(x => x.Get(It.IsAny())) - .Returns(new OkResponse>>(handlers)); - } - - private void GivenTheFactoryReturnsNothing() - { - var handlers = new List>(); - - _factory - .Setup(x => x.Get(It.IsAny())) - .Returns(new OkResponse>>(handlers)); - } - - private void GivenTheFactoryReturns(List> handlers) - { - _factory - .Setup(x => x.Get(It.IsAny())) - .Returns(new OkResponse>>(handlers)); - } - - private void WhenIBuild() - { - _httpClient = _builder.Create(_context.Items.DownstreamRoute()); - } - - private void WhenIBuildTheFirstTime() - { - _firstHttpClient = _builder.Create(_context.Items.DownstreamRoute()); - } - - private void WhenIBuildAgain() - { - _builder = new HttpClientBuilder(_factory.Object, _realCache, _logger.Object); - _againHttpClient = _builder.Create(_context.Items.DownstreamRoute()); - } - - private void ThenTheHttpClientShouldNotBeNull() - { - _httpClient.ShouldNotBeNull(); - } - - public void Dispose() - { - _response?.Dispose(); - _host?.Dispose(); - } - } -} diff --git a/test/Ocelot.UnitTests/Requester/HttpClientHttpRequesterTest.cs b/test/Ocelot.UnitTests/Requester/HttpClientHttpRequesterTest.cs deleted file mode 100644 index 76324d160..000000000 --- a/test/Ocelot.UnitTests/Requester/HttpClientHttpRequesterTest.cs +++ /dev/null @@ -1,188 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; -using Ocelot.Configuration.Builder; -using Ocelot.Logging; -using Ocelot.Middleware; -using Ocelot.Request.Middleware; -using Ocelot.Requester; -using Ocelot.Responses; - -namespace Ocelot.UnitTests.Requester -{ - public class HttpClientHttpRequesterTest - { - private readonly Mock _cacheHandlers; - private readonly Mock _factory; - private Response _response; - private readonly HttpClientHttpRequester _httpClientRequester; - private readonly Mock _loggerFactory; - private readonly Mock _logger; - private readonly Mock _mapper; - private HttpContext _httpContext; - - public HttpClientHttpRequesterTest() - { - _httpContext = new DefaultHttpContext(); - _factory = new Mock(); - _factory.Setup(x => x.Get(It.IsAny())).Returns(new OkResponse>>(new List>())); - _logger = new Mock(); - _loggerFactory = new Mock(); - _loggerFactory - .Setup(x => x.CreateLogger()) - .Returns(_logger.Object); - _cacheHandlers = new Mock(); - _mapper = new Mock(); - _httpClientRequester = new HttpClientHttpRequester( - _loggerFactory.Object, - _cacheHandlers.Object, - _factory.Object, - _mapper.Object); - } - - [Fact] - public void should_call_request_correctly() - { - var upstreamTemplate = new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build(); - - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(upstreamTemplate) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - var httpContext = new DefaultHttpContext(); - httpContext.Items.UpsertDownstreamRoute(route); - httpContext.Items.UpsertDownstreamRequest(new DownstreamRequest(new HttpRequestMessage { RequestUri = new Uri("http://www.bbc.co.uk") })); - - this.Given(x => x.GivenTheRequestIs(httpContext)) - .And(x => GivenTheHouseReturnsOkHandler()) - .When(x => x.WhenIGetResponse()) - .Then(x => x.ThenTheResponseIsCalledCorrectly()) - .BDDfy(); - } - - [Fact] - public void should_call_request_unable_to_complete_request() - { - var upstreamTemplate = new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build(); - - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(upstreamTemplate) - .WithQosOptions(new QoSOptionsBuilder().Build()) - .Build(); - - var httpContext = new DefaultHttpContext(); - httpContext.Items.UpsertDownstreamRoute(route); - httpContext.Items.UpsertDownstreamRequest(new DownstreamRequest(new HttpRequestMessage { RequestUri = new Uri("http://localhost:60080") })); - - this.Given(x => x.GivenTheRequestIs(httpContext)) - .When(x => x.WhenIGetResponse()) - .Then(x => x.ThenTheResponseIsCalledError()) - .BDDfy(); - } - - [Fact] - public void http_client_request_times_out() - { - var upstreamTemplate = new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build(); - - var qosOptions = new QoSOptionsBuilder() - .Build(); - - var route = new DownstreamRouteBuilder() - .WithQosOptions(qosOptions) - .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue)) - .WithLoadBalancerKey(string.Empty) - .WithUpstreamPathTemplate(upstreamTemplate) - .WithQosOptions(new QoSOptionsBuilder().WithTimeoutValue(1).Build()) - .Build(); - - var httpContext = new DefaultHttpContext(); - httpContext.Items.UpsertDownstreamRoute(route); - httpContext.Items.UpsertDownstreamRequest(new DownstreamRequest(new HttpRequestMessage { RequestUri = new Uri("http://localhost:60080") })); - - this.Given(_ => GivenTheRequestIs(httpContext)) - .And(_ => GivenTheHouseReturnsTimeoutHandler()) - .When(_ => WhenIGetResponse()) - .Then(_ => ThenTheResponseIsCalledError()) - .And(_ => ThenTheErrorIsTimeout()) - .BDDfy(); - } - - private void GivenTheRequestIs(HttpContext httpContext) - { - _httpContext = httpContext; - } - - private void WhenIGetResponse() - { - _response = _httpClientRequester.GetResponse(_httpContext).GetAwaiter().GetResult(); - } - - private void ThenTheResponseIsCalledCorrectly() - { - _response.IsError.ShouldBeFalse(); - } - - private void ThenTheResponseIsCalledError() - { - _response.IsError.ShouldBeTrue(); - } - - private void ThenTheErrorIsTimeout() - { - _mapper.Verify(x => x.Map(It.IsAny()), Times.Once); - _response.Errors[0].ShouldBeOfType(); - } - - private void GivenTheHouseReturnsOkHandler() - { - var handlers = new List> - { - () => new OkDelegatingHandler(), - }; - - _factory.Setup(x => x.Get(It.IsAny())).Returns(new OkResponse>>(handlers)); - } - - private void GivenTheHouseReturnsTimeoutHandler() - { - var handlers = new List> - { - () => new TimeoutDelegatingHandler(), - }; - - _factory.Setup(x => x.Get(It.IsAny())).Returns(new OkResponse>>(handlers)); - - _mapper.Setup(x => x.Map(It.IsAny())).Returns(new UnableToCompleteRequestError(new Exception())); - } - - private class OkDelegatingHandler : DelegatingHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - return Task.FromResult(new HttpResponseMessage()); - } - } - - private class TimeoutDelegatingHandler : DelegatingHandler - { - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - await Task.Delay(100000, cancellationToken); - return new HttpResponseMessage(); - } - } - } -} diff --git a/test/Ocelot.UnitTests/Requester/MessageInvokerPoolTests.cs b/test/Ocelot.UnitTests/Requester/MessageInvokerPoolTests.cs new file mode 100644 index 000000000..fd4eb7d4d --- /dev/null +++ b/test/Ocelot.UnitTests/Requester/MessageInvokerPoolTests.cs @@ -0,0 +1,341 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using Ocelot.Configuration.Builder; +using Ocelot.Configuration.File; +using Ocelot.Logging; +using Ocelot.Middleware; +using Ocelot.Request.Middleware; +using Ocelot.Requester; +using Ocelot.Responses; +using System.Diagnostics; + +namespace Ocelot.UnitTests.Requester; + +[Trait("PR", "1824")] +public class MessageInvokerPoolTests +{ + private DownstreamRoute _downstreamRoute1; + private DownstreamRoute _downstreamRoute2; + private MessageInvokerPool _pool; + private HttpMessageInvoker _firstInvoker; + private HttpMessageInvoker _secondInvoker; + private Mock _handlerFactory; + private readonly Mock _ocelotLoggerFactory; + private readonly Mock _ocelotLogger; + private HttpContext _context; + private HttpResponseMessage _response; + private IWebHost _host; + + public MessageInvokerPoolTests() + { + _ocelotLoggerFactory = new Mock(); + _ocelotLogger = new Mock(); + _ocelotLoggerFactory.Setup(x => x.CreateLogger()).Returns(_ocelotLogger.Object); + } + + [Fact] + public void If_calling_the_same_downstream_route_twice_should_return_the_same_message_invoker() + { + this.Given(x => x.GivenADownstreamRoute("/super-test")) + .And(x => x.AndAHandlerFactory()) + .And(x => x.GivenAMessageInvokerPool()) + .When(x => x.WhenGettingMessageInvokerTwice()) + .Then(x => x.ThenTheInvokersShouldBeTheSame()) + .BDDfy(); + } + + [Fact] + public void If_calling_two_different_downstream_routes_should_return_different_message_invokers() + { + this.Given(x => x.GivenTwoDifferentDownstreamRoutes("/super-test", "/super-test")) + .And(x => x.AndAHandlerFactory()) + .And(x => x.GivenAMessageInvokerPool()) + .When(x => x.WhenGettingMessageInvokerForBothRoutes()) + .Then(x => x.ThenTheInvokersShouldNotBeTheSame()) + .BDDfy(); + } + + [Fact] + public void If_two_delegating_handlers_are_defined_then_these_should_be_call_in_order() + { + var fakeOne = new FakeDelegatingHandler(); + var fakeTwo = new FakeDelegatingHandler(); + + var handlers = new List> + { + () => fakeOne, + () => fakeTwo, + }; + + this.Given(x => GivenTheFactoryReturns(handlers)) + .And(x => GivenADownstreamRoute("/super-test")) + .And(x => GivenAMessageInvokerPool()) + .And(x => GivenARequest()) + .When(x => WhenICallTheClient("http://www.bbc.co.uk")) + .Then(x => ThenTheFakeAreHandledInOrder(fakeOne, fakeTwo)) + .And(x => ThenSomethingIsReturned()) + .BDDfy(); + } + + [Fact] + public void Should_log_if_ignoring_ssl_errors() + { + var qosOptions = new QoSOptionsBuilder() + .Build(); + + var route = new DownstreamRouteBuilder() + .WithQosOptions(qosOptions) + .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, true, int.MaxValue, TimeSpan.FromSeconds(90))) + .WithLoadBalancerKey(string.Empty) + .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) + .WithQosOptions(new QoSOptionsBuilder().Build()) + .WithDangerousAcceptAnyServerCertificateValidator(true) + .Build(); + + this.Given(x => GivenTheFactoryReturns(new List>())) + .And(x => GivenAMessageInvokerPool()) + .And(x => GivenARequest(route)) + .When(x => WhenICallTheClient("http://www.bbc.co.uk")) + .Then(x => ThenTheDangerousAcceptAnyServerCertificateValidatorWarningIsLogged()) + .BDDfy(); + } + + [Fact] + public void Should_re_use_cookies_from_container() + { + var qosOptions = new QoSOptionsBuilder() + .Build(); + + var route = new DownstreamRouteBuilder() + .WithQosOptions(qosOptions) + .WithHttpHandlerOptions(new HttpHandlerOptions(false, true, false, true, int.MaxValue, TimeSpan.FromSeconds(90))) + .WithLoadBalancerKey(string.Empty) + .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) + .WithQosOptions(new QoSOptionsBuilder().Build()) + .Build(); + + this.Given(_ => GivenADownstreamService()) + .And(x => GivenTheFactoryReturns(new List>())) + .And(x => GivenAMessageInvokerPool()) + .And(x => GivenARequest(route)) + .And(_ => WhenICallTheClient("http://localhost:5003")) + .And(_ => ThenTheCookieIsSet()) + .When(_ => WhenICallTheClient("http://localhost:5003")) + .Then(_ => ThenTheResponseIsOk()) + .BDDfy(); + } + + [Theory] + [Trait("Issue", "1833")] + [InlineData(5, 5)] + [InlineData(10, 10)] + public void Create_TimeoutValueInQosOptions_MessageInvokerTimeout(int qosTimeout, int expectedSeconds) + { + // Arrange + var qosOptions = new QoSOptionsBuilder() + .WithTimeoutValue(qosTimeout * 1000) + .Build(); + var handlerOptions = new HttpHandlerOptionsBuilder() + .WithUseMaxConnectionPerServer(int.MaxValue) + .Build(); + var route = new DownstreamRouteBuilder() + .WithQosOptions(qosOptions) + .WithHttpHandlerOptions(handlerOptions) + .Build(); + GivenTheFactoryReturnsNothing(); + + this.Given(x => GivenTheFactoryReturns(new List>())) + .And(x => GivenAMessageInvokerPool()) + .And(x => GivenARequest(route)) + .Then(x => WhenICallTheClientWillThrowAfterTimeout(TimeSpan.FromSeconds(expectedSeconds))) + .BDDfy(); + } + + private void ThenTheDangerousAcceptAnyServerCertificateValidatorWarningIsLogged() + { + _ocelotLogger.Verify(x => x.LogWarning( + It.Is>(y => y.Invoke() == $"You have ignored all SSL warnings by using DangerousAcceptAnyServerCertificateValidator for this DownstreamRoute, UpstreamPathTemplate: {_context.Items.DownstreamRoute().UpstreamPathTemplate}, DownstreamPathTemplate: {_context.Items.DownstreamRoute().DownstreamPathTemplate}")), + Times.Once); + } + + private void ThenTheCookieIsSet() + { + _response.Headers.TryGetValues("Set-Cookie", out var test).ShouldBeTrue(); + } + + private void GivenADownstreamService() + { + var count = 0; + _host = new WebHostBuilder() + .UseUrls("http://localhost:5003") + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .Configure(app => + { + app.Run(context => + { + if (count == 0) + { + context.Response.Cookies.Append("test", "0"); + context.Response.StatusCode = 200; + count++; + return Task.CompletedTask; + } + + if (count == 1) + { + if (context.Request.Cookies.TryGetValue("test", out var cookieValue) || + context.Request.Headers.TryGetValue("Set-Cookie", out var headerValue)) + { + context.Response.StatusCode = 200; + return Task.CompletedTask; + } + + context.Response.StatusCode = 500; + } + + return Task.CompletedTask; + }); + }) + .Build(); + + _host.Start(); + } + + private void ThenTheResponseIsOk() + { + _response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + private void GivenARequest(DownstreamRoute downstream) + { + GivenARequest(downstream, "http://localhost:5003"); + } + + private void GivenARequest(DownstreamRoute downstream, string downstreamUrl) + { + GivenARequestWithAUrlAndMethod(downstream, downstreamUrl, HttpMethod.Get); + } + + private void GivenADownstreamRoute(string path) => _downstreamRoute1 = DownstreamRouteFactory(path); + + private void GivenTwoDifferentDownstreamRoutes(string path1, string path2) + { + _downstreamRoute1 = DownstreamRouteFactory(path1); + _downstreamRoute2 = DownstreamRouteFactory(path2); + } + + private void AndAHandlerFactory() => _handlerFactory = GetHandlerFactory(); + + private void GivenAMessageInvokerPool() => + _pool = new MessageInvokerPool(_handlerFactory.Object, _ocelotLoggerFactory.Object); + + private void WhenGettingMessageInvokerTwice() + { + _firstInvoker = _pool.Get(_downstreamRoute1); + _secondInvoker = _pool.Get(_downstreamRoute1); + } + + private void WhenGettingMessageInvokerForBothRoutes() + { + _firstInvoker = _pool.Get(_downstreamRoute1); + _secondInvoker = _pool.Get(_downstreamRoute2); + } + + private void ThenTheInvokersShouldBeTheSame() => Assert.Equal(_firstInvoker, _secondInvoker); + + private void ThenTheInvokersShouldNotBeTheSame() => Assert.NotEqual(_firstInvoker, _secondInvoker); + + private void GivenARequest(string url) => GivenARequestWithAUrlAndMethod(_downstreamRoute1, url, HttpMethod.Get); + + private void GivenARequest() => + GivenARequestWithAUrlAndMethod(_downstreamRoute1, "http://localhost:5003", HttpMethod.Get); + + private void GivenARequestWithAUrlAndMethod(DownstreamRoute downstream, string url, HttpMethod method) + { + _context = new DefaultHttpContext(); + _context.Items.UpsertDownstreamRoute(downstream); + _context.Items.UpsertDownstreamRequest(new DownstreamRequest(new HttpRequestMessage + { RequestUri = new Uri(url), Method = method })); + } + + private void ThenSomethingIsReturned() => _response.ShouldNotBeNull(); + + private void WhenICallTheClient(string url) + { + var messageInvoker = _pool.Get(_context.Items.DownstreamRoute()); + _response = messageInvoker + .SendAsync(new HttpRequestMessage(HttpMethod.Get, url), CancellationToken.None).GetAwaiter() + .GetResult(); + } + + private void WhenICallTheClientWillThrowAfterTimeout(TimeSpan timeout) + { + var messageInvoker = _pool.Get(_context.Items.DownstreamRoute()); + var stopwatch = new Stopwatch(); + try + { + stopwatch.Start(); + _response = messageInvoker + .SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com"), CancellationToken.None).GetAwaiter() + .GetResult(); + } + catch (Exception e) + { + stopwatch.Stop(); + var elapsed = stopwatch.Elapsed; + + // Compare the elapsed time with the given timeout + // You can use elapsed.CompareTo(timeout) or simply check if elapsed > timeout, based on your requirement + Assert.IsType(e); + Assert.True(elapsed >= timeout.Subtract(TimeSpan.FromMilliseconds(500)), $"Elapsed time {elapsed} is smaller than expected timeout {timeout} - 500 ms"); + Assert.True(elapsed < timeout.Add(TimeSpan.FromMilliseconds(500)), $"Elapsed time {elapsed} is bigger than expected timeout {timeout} + 500 ms"); + } + } + + private static void ThenTheFakeAreHandledInOrder(FakeDelegatingHandler fakeOne, FakeDelegatingHandler fakeTwo) => + fakeOne.TimeCalled.ShouldBeGreaterThan(fakeTwo.TimeCalled); + + private void GivenTheFactoryReturnsNothing() + { + var handlers = new List>(); + + _handlerFactory = new Mock(); + _handlerFactory + .Setup(x => x.Get(It.IsAny())) + .Returns(new OkResponse>>(handlers)); + } + + private void GivenTheFactoryReturns(List> handlers) + { + _handlerFactory = new Mock(); + _handlerFactory + .Setup(x => x.Get(It.IsAny())) + .Returns(new OkResponse>>(handlers)); + } + + private Mock GetHandlerFactory() + { + var handlerFactory = new Mock(); + handlerFactory.Setup(x => x.Get(It.IsAny())) + .Returns(new OkResponse>>([])); + return handlerFactory; + } + + private DownstreamRoute DownstreamRouteFactory(string path) + { + var downstreamRoute = new DownstreamRouteBuilder() + .WithDownstreamPathTemplate(path) + .WithQosOptions(new QoSOptions(new FileQoSOptions())) + .WithLoadBalancerKey(string.Empty) + .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder().WithOriginalValue(string.Empty).Build()) + .WithHttpHandlerOptions(new HttpHandlerOptions(false, false, false, false, 10, TimeSpan.FromSeconds(120))) + .WithUpstreamHttpMethod(["Get"]) + .Build(); + + return downstreamRoute; + } +} From f4803c24bf9e9ca3929c78ca8eb23401e3c31c23 Mon Sep 17 00:00:00 2001 From: Aly Kafoury <34947215+AlyHKafoury@users.noreply.github.com> Date: Thu, 18 Jan 2024 20:23:03 +0200 Subject: [PATCH 6/9] #748 Match Route configurations for upstream paths when empty Catch-All placeholders at the end of template (#1911) * Update RoutingTests.cs * Fix end of line empty placeholder * Fix unit tests * Fix PR Comments * Update src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs Co-authored-by: Raman Maksimchuk * Update src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs Co-authored-by: Raman Maksimchuk * Update test/Ocelot.AcceptanceTests/RoutingTests.cs Co-authored-by: Raman Maksimchuk * Update test/Ocelot.AcceptanceTests/RoutingTests.cs Co-authored-by: Raman Maksimchuk * Update test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs Co-authored-by: Raman Maksimchuk * compact the tests constant name * remove constant * Update RoutingTests.cs * Update RoutingTests.cs * Update RoutingTests.cs * Update UrlPathPlaceholderNameAndValueFinderTests.cs * Use range operator * Use expression body for method * Update src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs Co-authored-by: Raman Maksimchuk * Update DownstreamUrlCreatorMiddleware.cs * add extra unit tests * Update RoutingTests.cs * Clean code * Update UrlPathPlaceholderNameAndValueFinderTests.cs * Update DownstreamUrlCreatorMiddlewareTests.cs * Update DownstreamUrlCreatorMiddlewareTests.cs * Fix broken dsPath * Review tests. Add query string scenarios * Fix unit test and fix +1 issue * Add final routing tests for Catch-All query string cases * Fixed added unit tests * Test traits * Update docs of Routing feature --------- Co-authored-by: Raman Maksimchuk --- docs/features/routing.rst | 39 +++- docs/introduction/gotchas.rst | 6 +- .../Creator/UpstreamTemplatePatternCreator.cs | 14 +- .../UrlPathPlaceholderNameAndValueFinder.cs | 67 ++++--- .../DownstreamUrlCreatorMiddleware.cs | 86 +++++---- test/Ocelot.AcceptanceTests/RoutingTests.cs | 182 +++++++++++++----- .../UpstreamTemplatePatternCreatorTests.cs | 19 +- ...lPathPlaceholderNameAndValueFinderTests.cs | 54 ++++++ .../DownstreamUrlCreatorMiddlewareTests.cs | 42 ++++ 9 files changed, 375 insertions(+), 134 deletions(-) diff --git a/docs/features/routing.rst b/docs/features/routing.rst index e2ca941bf..f7fdd20c8 100644 --- a/docs/features/routing.rst +++ b/docs/features/routing.rst @@ -36,6 +36,8 @@ The **UpstreamPathTemplate** property is the URL that Ocelot will use to identif The **UpstreamHttpMethod** is used so Ocelot can distinguish between requests with different HTTP verbs to the same URL. You can set a specific list of HTTP methods or set an empty list to allow any of them. +.. _routing-placeholders: + Placeholders ------------ @@ -43,7 +45,7 @@ In Ocelot you can add placeholders for variables to your Templates in the form o The placeholder variable needs to be present in both the **DownstreamPathTemplate** and **UpstreamPathTemplate** properties. When it is Ocelot will attempt to substitute the value in the **UpstreamPathTemplate** placeholder into the **DownstreamPathTemplate** for each request Ocelot processes. -You can also do a `Catch All <#catch-all>`_ type of Route e.g. +You can also do a :ref:`routing-catch-all` type of Route e.g. .. code-block:: json @@ -69,6 +71,36 @@ In order to change this you can specify on a per Route basis the following setti This means that when Ocelot tries to match the incoming upstream URL with an upstream template the evaluation will be case sensitive. +.. _routing-empty-placeholders: + +Empty Placeholders +^^^^^^^^^^^^^^^^^^ + +This is a special edge case of :ref:`routing-placeholders`, where the value of the placeholder is simply an empty string ``""``. + +For example, **Given a route**: + +.. code-block:: json + + { + "UpstreamPathTemplate": "/invoices/{url}", + "DownstreamPathTemplate": "/api/invoices/{url}", + } + +.. role:: htm(raw) + :format: html + +| **Then**, it works correctly when ``{url}`` is specified: ``/invoices/123`` :htm:`→` ``/api/invoices/123``. +| **And then**, there are two edge cases with empty placeholder value: + +* Also, it works when ``{url}`` is empty. We would expect upstream path ``/invoices/`` to route to downstream path ``/api/invoices/`` +* Moreover, it should work when omitting last slash. We also expect upstream ``/invoices`` to be routed to downstream ``/api/invoices``, which is intuitive to humans + +This feature is available starting from Ocelot version `23.0 `_, +see more in issue `748 `_ and release `23.0 `__ notes. + +.. _routing-catch-all: + Catch All --------- @@ -211,7 +243,7 @@ Note, the best practice is giving different placeholder name than the name of qu Catch All Query String ^^^^^^^^^^^^^^^^^^^^^^ -Ocelot's routing also supports a *Catch All* style routing to forward all query string parameters. +Ocelot's routing also supports a :ref:`routing-catch-all` style routing to forward all query string parameters. The placeholder ``{everything}`` name does not matter, any name will work. .. code-block:: json @@ -224,6 +256,9 @@ The placeholder ``{everything}`` name does not matter, any name will work. This entire query string routing feature is very useful in cases where the query string should not be transformed but rather routed without any changes, such as OData filters and etc (see issue `1174 `_). +**Note**, the ``{everything}`` placeholder can be empty while catching all query strings, because this is a part of the :ref:`routing-empty-placeholders` feature! +Thus, upstream paths ``/contracts?`` and ``/contracts`` are routed to downstream path ``/apipath/contracts``, which has no query string at all. + Restrictions on use ^^^^^^^^^^^^^^^^^^^ diff --git a/docs/introduction/gotchas.rst b/docs/introduction/gotchas.rst index a532b3fd5..ec744ea09 100644 --- a/docs/introduction/gotchas.rst +++ b/docs/introduction/gotchas.rst @@ -39,9 +39,9 @@ We try to optimize Ocelot web app for Kestrel & Docker hosting scenarios, but ke We believe that your client apps should have direct integration to (static) files persistent storages and services: remote & destributed file systems, CDNs, static files & blob storages, etc. We **do not** recommend to pump large files (100Mb+ or even larger 1GB+) using gateway because of performance reasons: consuming memory and CPU, long delay times, producing network errors for downstream streaming, impact on other routes. - | The community constanly reports issues related to `large files `_ (``application/octet-stream`` content type, :ref:`chunked-encoding`, etc.), see issues `749 `_, `1472 `_. - If you still want to pump large files through an Ocelot gateway instance, we believe our PRs (`1724 `_, `1769 `_) will help resolve the issues and stabilize large content proxying. - In case of some errors, see the next point. + | The community constanly reports issues related to `large files `_, ``application/octet-stream`` content type, :ref:`chunked-encoding`, etc., see issues `749 `_, `1472 `_. + | If you still want to pump large files through an Ocelot gateway instance, we believe our PRs `1724 `_, `1769 `_ will help resolve the issues and stabilize large content proxying. + | In case of some errors, see the next point. * **Maximum request body size**. ASP.NET ``HttpRequest`` behaves erroneously for application instances that do not have their Kestrel `MaxRequestBodySize `_ option configured correctly and having pumped large files of unpredictable size which exceeds the limit. diff --git a/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs b/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs index 342373235..a5d0bcf7b 100644 --- a/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs +++ b/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs @@ -1,11 +1,11 @@ using Ocelot.Configuration.File; -using Ocelot.Values; +using Ocelot.Values; namespace Ocelot.Configuration.Creator { public class UpstreamTemplatePatternCreator : IUpstreamTemplatePatternCreator { - private const string RegExMatchOneOrMoreOfEverything = ".+"; + public const string RegExMatchZeroOrMoreOfEverything = ".*"; private const string RegExMatchOneOrMoreOfEverythingUntilNextForwardSlash = "[^/]+"; private const string RegExMatchEndString = "$"; private const string RegExIgnoreCase = "(?i)"; @@ -40,7 +40,7 @@ public UpstreamPathTemplate Create(IRoute route) if (upstreamTemplate.Contains('?')) { containsQueryString = true; - upstreamTemplate = upstreamTemplate.Replace("?", "\\?"); + upstreamTemplate = upstreamTemplate.Replace("?", "(|\\?)"); } for (var i = 0; i < placeholders.Count; i++) @@ -49,7 +49,7 @@ public UpstreamPathTemplate Create(IRoute route) var indexOfNextForwardSlash = upstreamTemplate.IndexOf("/", indexOfPlaceholder, StringComparison.Ordinal); if (indexOfNextForwardSlash < indexOfPlaceholder || (containsQueryString && upstreamTemplate.IndexOf('?', StringComparison.Ordinal) < upstreamTemplate.IndexOf(placeholders[i], StringComparison.Ordinal))) { - upstreamTemplate = upstreamTemplate.Replace(placeholders[i], RegExMatchOneOrMoreOfEverything); + upstreamTemplate = upstreamTemplate.Replace(placeholders[i], RegExMatchZeroOrMoreOfEverything); } else { @@ -60,6 +60,12 @@ public UpstreamPathTemplate Create(IRoute route) if (upstreamTemplate == "/") { return new UpstreamPathTemplate(RegExForwardSlashOnly, route.Priority, containsQueryString, route.UpstreamPathTemplate); + } + + var index = upstreamTemplate.LastIndexOf('/'); // index of last forward slash + if (index < (upstreamTemplate.Length - 1) && upstreamTemplate[index + 1] == '.') + { + upstreamTemplate = upstreamTemplate[..index] + "(?:|/" + upstreamTemplate[++index..] + ")"; } if (upstreamTemplate.EndsWith("/")) diff --git a/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs b/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs index 60ecdbf21..d77f5f4d1 100644 --- a/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs +++ b/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs @@ -17,44 +17,41 @@ public Response> Find(string path, string query, s for (var counterForTemplate = 0; counterForTemplate < pathTemplate.Length; counterForTemplate++) { - if ((path.Length > counterForPath) && CharactersDontMatch(pathTemplate[counterForTemplate], path[counterForPath]) && ContinueScanningUrl(counterForPath, path.Length)) + if (ContinueScanningUrl(counterForPath, path.Length) + && CharactersDontMatch(pathTemplate[counterForTemplate], path[counterForPath]) + && IsPlaceholder(pathTemplate[counterForTemplate])) { - if (IsPlaceholder(pathTemplate[counterForTemplate])) + //should_find_multiple_query_string make test pass + if (PassedQueryString(pathTemplate, counterForTemplate)) { - //should_find_multiple_query_string make test pass - if (PassedQueryString(pathTemplate, counterForTemplate)) - { - delimiter = '&'; - nextDelimiter = '&'; - } + delimiter = '&'; + nextDelimiter = '&'; + } - //should_find_multiple_query_string_and_path makes test pass - if (NotPassedQueryString(pathTemplate, counterForTemplate) && NoMoreForwardSlash(pathTemplate, counterForTemplate)) - { - delimiter = '?'; - nextDelimiter = '?'; - } + //should_find_multiple_query_string_and_path makes test pass + if (NotPassedQueryString(pathTemplate, counterForTemplate) && NoMoreForwardSlash(pathTemplate, counterForTemplate)) + { + delimiter = '?'; + nextDelimiter = '?'; + } - var placeholderName = GetPlaceholderName(pathTemplate, counterForTemplate); + var placeholderName = GetPlaceholderName(pathTemplate, counterForTemplate); - var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath, delimiter); + var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath, delimiter); - placeHolderNameAndValues.Add(new PlaceholderNameAndValue(placeholderName, placeholderValue)); + placeHolderNameAndValues.Add(new PlaceholderNameAndValue(placeholderName, placeholderValue)); - counterForTemplate = GetNextCounterPosition(pathTemplate, counterForTemplate, '}'); + counterForTemplate = GetNextCounterPosition(pathTemplate, counterForTemplate, '}'); - counterForPath = GetNextCounterPosition(path, counterForPath, nextDelimiter); + counterForPath = GetNextCounterPosition(path, counterForPath, nextDelimiter); - continue; - } - - return new OkResponse>(placeHolderNameAndValues); + continue; } - else if (IsCatchAll(path, counterForPath, pathTemplate)) + else if (IsCatchAll(path, counterForPath, pathTemplate) || IsCatchAllAfterOtherPlaceholders(pathTemplate, counterForTemplate)) { var endOfPlaceholder = GetNextCounterPosition(pathTemplate, counterForTemplate, '}'); - var placeholderName = GetPlaceholderName(pathTemplate, 1); + var placeholderName = GetPlaceholderName(pathTemplate, counterForTemplate + 1); if (NothingAfterFirstForwardSlash(path)) { @@ -62,11 +59,13 @@ public Response> Find(string path, string query, s } else { - var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath + 1, '?'); + var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath, '?'); placeHolderNameAndValues.Add(new PlaceholderNameAndValue(placeholderName, placeholderValue)); } counterForTemplate = endOfPlaceholder; + counterForPath = GetNextCounterPosition(path, counterForPath, '?'); + continue; } counterForPath++; @@ -97,6 +96,12 @@ private static bool IsCatchAll(string path, int counterForPath, string pathTempl && pathTemplate.IndexOf('}') == pathTemplate.Length - 1; } + private static bool IsCatchAllAfterOtherPlaceholders(string pathTemplate, int counterForTemplate) + => (pathTemplate[counterForTemplate] == '/' || pathTemplate[counterForTemplate] == '?') + && (counterForTemplate < pathTemplate.Length - 1) + && (pathTemplate[counterForTemplate + 1] == '{') + && NoMoreForwardSlash(pathTemplate, counterForTemplate + 1); + private static bool NothingAfterFirstForwardSlash(string path) { return path.Length == 1 || path.Length == 0; @@ -104,6 +109,16 @@ private static bool NothingAfterFirstForwardSlash(string path) private static string GetPlaceholderValue(string urlPathTemplate, string query, string variableName, string urlPath, int counterForUrl, char delimiter) { + if (counterForUrl >= urlPath.Length) + { + return string.Empty; + } + + if ( urlPath[counterForUrl] == '/') + { + counterForUrl++; + } + var positionOfNextSlash = urlPath.IndexOf(delimiter, counterForUrl); if (positionOfNextSlash == -1 || (urlPathTemplate.Trim(delimiter).EndsWith(variableName) && string.IsNullOrEmpty(query))) diff --git a/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs b/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs index f8ae6ed26..a92f6a470 100644 --- a/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs +++ b/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs @@ -1,12 +1,12 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; -using Ocelot.DownstreamRouteFinder.UrlMatcher; -using Ocelot.Logging; -using Ocelot.Middleware; -using Ocelot.Request.Middleware; -using Ocelot.Responses; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using Ocelot.DownstreamRouteFinder.UrlMatcher; +using Ocelot.Logging; +using Ocelot.Middleware; +using Ocelot.Request.Middleware; +using Ocelot.Responses; using Ocelot.Values; -using System.Web; +using System.Web; namespace Ocelot.DownstreamUrlCreator.Middleware { @@ -15,12 +15,13 @@ public class DownstreamUrlCreatorMiddleware : OcelotMiddleware private readonly RequestDelegate _next; private readonly IDownstreamPathPlaceholderReplacer _replacer; - private const char Ampersand = '&'; - private const char QuestionMark = '?'; - private const char OpeningBrace = '{'; - private const char ClosingBrace = '}'; + private const char Ampersand = '&'; + private const char QuestionMark = '?'; + private const char OpeningBrace = '{'; + private const char ClosingBrace = '}'; + protected const char Slash = '/'; - public DownstreamUrlCreatorMiddleware( + public DownstreamUrlCreatorMiddleware( RequestDelegate next, IOcelotLoggerFactory loggerFactory, IDownstreamPathPlaceholderReplacer replacer) @@ -36,6 +37,7 @@ public async Task Invoke(HttpContext httpContext) var placeholders = httpContext.Items.TemplatePlaceholderNameAndValues(); var response = _replacer.Replace(downstreamRoute.DownstreamPathTemplate.Value, placeholders); var downstreamRequest = httpContext.Items.DownstreamRequest(); + var upstreamPath = downstreamRequest.AbsolutePath; if (response.IsError) { @@ -44,10 +46,17 @@ public async Task Invoke(HttpContext httpContext) httpContext.Items.UpsertErrors(response.Errors); return; } + + var dsPath = response.Data.Value; + if (dsPath.EndsWith(Slash) && !upstreamPath.EndsWith(Slash)) + { + dsPath = dsPath.TrimEnd(Slash); + response = new OkResponse(new DownstreamPath(dsPath)); + } if (!string.IsNullOrEmpty(downstreamRoute.DownstreamScheme)) { - //todo make sure this works, hopefully there is a test ;E + // TODO Make sure this works, hopefully there is a test ;E httpContext.Items.DownstreamRequest().Scheme = downstreamRoute.DownstreamScheme; } @@ -57,26 +66,25 @@ public async Task Invoke(HttpContext httpContext) { var (path, query) = CreateServiceFabricUri(downstreamRequest, downstreamRoute, placeholders, response); - //todo check this works again hope there is a test.. + // TODO Check this works again hope there is a test.. downstreamRequest.AbsolutePath = path; downstreamRequest.Query = query; } else { - var dsPath = response.Data; - if (dsPath.Value.Contains(QuestionMark)) + if (dsPath.Contains(QuestionMark)) { downstreamRequest.AbsolutePath = GetPath(dsPath); var newQuery = GetQueryString(dsPath); downstreamRequest.Query = string.IsNullOrEmpty(downstreamRequest.Query) ? newQuery - : MergeQueryStringsWithoutDuplicateValues(downstreamRequest.Query, newQuery, placeholders); + : MergeQueryStringsWithoutDuplicateValues(downstreamRequest.Query, newQuery, placeholders); } else { RemoveQueryStringParametersThatHaveBeenUsedInTemplate(downstreamRequest, placeholders); - downstreamRequest.AbsolutePath = dsPath.Value; + downstreamRequest.AbsolutePath = dsPath; } } @@ -86,25 +94,25 @@ public async Task Invoke(HttpContext httpContext) } private static string MergeQueryStringsWithoutDuplicateValues(string queryString, string newQueryString, List placeholders) - { + { newQueryString = newQueryString.Replace(QuestionMark, Ampersand); var queries = HttpUtility.ParseQueryString(queryString); var newQueries = HttpUtility.ParseQueryString(newQueryString); - var parameters = newQueries.AllKeys - .Where(key => !string.IsNullOrEmpty(key)) + var parameters = newQueries.AllKeys + .Where(key => !string.IsNullOrEmpty(key)) .ToDictionary(key => key, key => newQueries[key]); - _ = queries.AllKeys - .Where(key => !string.IsNullOrEmpty(key) && !parameters.ContainsKey(key)) - .All(key => parameters.TryAdd(key, queries[key])); - - // Remove old replaced query parameters - foreach (var placeholder in placeholders) - { - parameters.Remove(placeholder.Name.Trim(OpeningBrace, ClosingBrace)); - } - + _ = queries.AllKeys + .Where(key => !string.IsNullOrEmpty(key) && !parameters.ContainsKey(key)) + .All(key => parameters.TryAdd(key, queries[key])); + + // Remove old replaced query parameters + foreach (var placeholder in placeholders) + { + parameters.Remove(placeholder.Name.Trim(OpeningBrace, ClosingBrace)); + } + var orderedParams = parameters.OrderBy(x => x.Key).Select(x => $"{x.Key}={x.Value}"); return QuestionMark + string.Join(Ampersand, orderedParams); } @@ -131,16 +139,16 @@ private static void RemoveQueryStringParametersThatHaveBeenUsedInTemplate(Downst } } - private static string GetPath(DownstreamPath dsPath) - { - int length = dsPath.Value.IndexOf(QuestionMark, StringComparison.Ordinal); - return dsPath.Value[..length]; + private static string GetPath(string downstreamPath) + { + int length = downstreamPath.IndexOf(QuestionMark, StringComparison.Ordinal); + return downstreamPath[..length]; } - private static string GetQueryString(DownstreamPath dsPath) + private static string GetQueryString(string downstreamPath) { - int startIndex = dsPath.Value.IndexOf(QuestionMark, StringComparison.Ordinal); - return dsPath.Value[startIndex..]; + int startIndex = downstreamPath.IndexOf(QuestionMark, StringComparison.Ordinal); + return downstreamPath[startIndex..]; } private (string Path, string Query) CreateServiceFabricUri(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute, List templatePlaceholderNameAndValues, Response dsPath) diff --git a/test/Ocelot.AcceptanceTests/RoutingTests.cs b/test/Ocelot.AcceptanceTests/RoutingTests.cs index 5dda3d8d4..e95d3fab8 100644 --- a/test/Ocelot.AcceptanceTests/RoutingTests.cs +++ b/test/Ocelot.AcceptanceTests/RoutingTests.cs @@ -3,16 +3,23 @@ namespace Ocelot.AcceptanceTests { - public class RoutingTests : IDisposable + public sealed class RoutingTests : IDisposable { private readonly Steps _steps; - private string _downstreamPath; private readonly ServiceHandler _serviceHandler; + private string _downstreamPath; + private string _downstreamQuery; public RoutingTests() { _serviceHandler = new ServiceHandler(); _steps = new Steps(); + } + + public void Dispose() + { + _serviceHandler.Dispose(); + _steps.Dispose(); } [Fact] @@ -42,7 +49,7 @@ public void should_not_match_forward_slash_in_pattern_before_next_forward_slash( }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/aaaaaaaaa/cards", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/aaaaaaaaa/cards", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/api/v1/aaaaaaaaa/cards")) @@ -87,7 +94,7 @@ public void should_return_response_200_with_forward_slash_and_placeholder_only() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -138,7 +145,7 @@ public void should_return_response_200_favouring_forward_slash_with_path_route() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/test")) @@ -188,7 +195,7 @@ public void should_return_response_200_favouring_forward_slash() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -239,7 +246,7 @@ public void should_return_response_200_favouring_forward_slash_route_because_it_ }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -275,7 +282,7 @@ public void should_return_response_200_with_nothing_and_placeholder_only() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway(string.Empty)) @@ -311,7 +318,7 @@ public void should_return_response_200_with_simple_url() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -320,8 +327,9 @@ public void should_return_response_200_with_simple_url() .BDDfy(); } - [Fact] - public void Bug() + [Fact] + [Trait("Bug", "134")] + public void should_fix_issue_134() { var port = PortFinder.GetRandomPort(); @@ -364,7 +372,7 @@ public void Bug() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/vacancy/1")) @@ -400,7 +408,7 @@ public void should_return_response_200_when_path_missing_forward_slash_as_first_ }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -436,7 +444,7 @@ public void should_return_response_200_when_host_has_trailing_slash() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -452,7 +460,6 @@ public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_templ { var port = PortFinder.GetRandomPort(); var downstreamBasePath = "/products"; - var configuration = new FileConfiguration { Routes = new List @@ -475,7 +482,7 @@ public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_templ }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamBasePath, 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamBasePath, HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway(url)) @@ -483,11 +490,11 @@ public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_templ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } - + [Theory] + [Trait("Bug", "649")] [InlineData("/account/authenticate")] [InlineData("/account/authenticate/")] - [Trait("Issue", "649")] public void should_fix_issue_649(string url) { var port = PortFinder.GetRandomPort(); @@ -514,7 +521,7 @@ public void should_fix_issue_649(string url) }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn(baseUrl, "/authenticate", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn(baseUrl, "/authenticate", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway(url)) @@ -522,7 +529,7 @@ public void should_fix_issue_649(string url) .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } - + [Fact] public void should_return_not_found_when_upstream_url_ends_with_forward_slash_but_template_does_not() { @@ -550,7 +557,7 @@ public void should_return_not_found_when_upstream_url_ends_with_forward_slash_bu }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/")) @@ -558,8 +565,10 @@ public void should_return_not_found_when_upstream_url_ends_with_forward_slash_bu .BDDfy(); } - [Fact] - public void should_return_not_found() + [Theory] + [InlineData("/products", "/products/{productId}", "/products/")] + + public void should_return_200_found(string downstreamPathTemplate, string upstreamPathTemplate, string requestURL) { var port = PortFinder.GetRandomPort(); @@ -569,7 +578,7 @@ public void should_return_not_found() { new() { - DownstreamPathTemplate = "/products", + DownstreamPathTemplate = downstreamPathTemplate, DownstreamScheme = "http", DownstreamHostAndPorts = new List { @@ -579,17 +588,18 @@ public void should_return_not_found() Port = port, }, }, - UpstreamPathTemplate = "/products/{productId}", + UpstreamPathTemplate = upstreamPathTemplate, UpstreamHttpMethod = new List { "Get" }, }, }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamPathTemplate, HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL)) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamPathTemplate)) .BDDfy(); } @@ -620,7 +630,7 @@ public void should_return_response_200_with_complex_url() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", 200, "Some Product")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", HttpStatusCode.OK, "Some Product")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/1")) @@ -656,7 +666,7 @@ public void should_return_response_200_with_complex_url_that_starts_with_placeho }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/23/products/1", 200, "Some Product")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/23/products/1", HttpStatusCode.OK, "Some Product")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("23/products/1")) @@ -692,7 +702,7 @@ public void should_not_add_trailing_slash_to_downstream_url() }, }; - this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", 200, "Some Product")) + this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", HttpStatusCode.OK, "Some Product")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/1")) @@ -727,7 +737,7 @@ public void should_return_response_201_with_simple_url() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 201, string.Empty)) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.Created, string.Empty)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .And(x => _steps.GivenThePostHasContent("postContent")) @@ -763,7 +773,7 @@ public void should_return_response_201_with_complex_query_string() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/newThing", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/newThing", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/newThing?DeviceType=IphoneApp&Browser=moonpigIphone&BrowserString=-&CountryCode=123&DeviceName=iPhone 5 (GSM+CDMA)&OperatingSystem=iPhone OS 7.1.2&BrowserVersion=3708AdHoc&ipAddress=-")) @@ -799,7 +809,7 @@ public void should_return_response_200_with_placeholder_for_final_url_path() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", 200, "Some Product")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", HttpStatusCode.OK, "Some Product")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/myApp1Name/api/products/1")) @@ -808,6 +818,58 @@ public void should_return_response_200_with_placeholder_for_final_url_path() .BDDfy(); } + [Theory] + [Trait("Bug", "748")] + [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test/1", "/downstream/test/1", "?p1=v1&p2=v2&something-else")] + [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test/", "/downstream/test/", "?p1=v1&p2=v2&something-else")] + [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test", "/downstream/test", "?p1=v1&p2=v2&something-else")] + [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test123", null, null)] + [InlineData("/downstream/{version}/test/{everything}", "/upstream/{version}/test/{everything}", "/upstream/v1/test/123", "/downstream/v1/test/123", "?p1=v1&p2=v2&something-else")] + [InlineData("/downstream/{version}/test", "/upstream/{version}/test", "/upstream/v1/test", "/downstream/v1/test", "?p1=v1&p2=v2&something-else")] + [InlineData("/downstream/{version}/test", "/upstream/{version}/test", "/upstream/test", null, null)] + public void should_return_correct_downstream_when_omitting_ending_placeholder(string downstreamPathTemplate, string upstreamPathTemplate, string requestURL, string downstreamURL, string queryString) + { + var port = PortFinder.GetRandomPort(); + var configuration = GivenDefaultConfiguration(port, upstreamPathTemplate, downstreamPathTemplate); + this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Aly")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL)) + .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamURL)) + + // Now check the same URL but with query string + // Catch-All placeholder should forward any path + query string combinations to the downstream service + // More: https://ocelot.readthedocs.io/en/latest/features/routing.html#placeholders:~:text=This%20will%20forward%20any%20path%20%2B%20query%20string%20combinations%20to%20the%20downstream%20service%20after%20the%20path%20%2Fapi. + .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL + queryString)) + .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamURL)) + .And(x => x.ThenTheDownstreamUrlQueryStringShouldBe(queryString)) + .BDDfy(); + } + + [Trait("PR", "1911")] + [Trait("Link", "https://ocelot.readthedocs.io/en/latest/features/routing.html#catch-all-query-string")] + [Theory(DisplayName = "Catch All Query String should be forwarded with all query string parameters with(out) last slash")] + [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts", "/apipath/contracts", "")] + [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts?", "/apipath/contracts", "")] + [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts?p1=v1&p2=v2", "/apipath/contracts", "?p1=v1&p2=v2")] + [InlineData("/apipath/contracts/?{everything}", "/contracts/?{everything}", "/contracts/?", "/apipath/contracts/", "")] + [InlineData("/apipath/contracts/?{everything}", "/contracts/?{everything}", "/contracts/?p3=v3&p4=v4", "/apipath/contracts/", "?p3=v3&p4=v4")] + [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts?filter=(-something+123+else)", "/apipath/contracts", "?filter=(-something%20123%20else)")] + public void Should_forward_Catch_All_query_string_when_last_slash(string downstream, string upstream, string requestURL, string downstreamPath, string queryString) + { + var port = PortFinder.GetRandomPort(); + var configuration = GivenDefaultConfiguration(port, upstream, downstream); + this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamPath, HttpStatusCode.OK, "Hello from Raman")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL)) + .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamPath)) // ! + .And(x => x.ThenTheDownstreamUrlQueryStringShouldBe(queryString)) // !! + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Raman")) + .BDDfy(); + } + [Fact] public void should_return_response_201_with_simple_url_and_multiple_upstream_http_method() { @@ -835,7 +897,7 @@ public void should_return_response_201_with_simple_url_and_multiple_upstream_htt }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", string.Empty, 201, string.Empty)) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", string.Empty, HttpStatusCode.Created, string.Empty)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .And(x => _steps.GivenThePostHasContent("postContent")) @@ -871,7 +933,7 @@ public void should_return_response_200_with_simple_url_and_any_upstream_http_met }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) @@ -924,7 +986,7 @@ public void should_return_404_when_calling_upstream_route_with_no_matching_downs }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("api/vacancy/1")) @@ -959,7 +1021,7 @@ public void should_not_set_trailing_slash_on_url_template() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/swagger/lib/backbone-min.js", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/swagger/lib/backbone-min.js", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/platform/swagger/lib/backbone-min.js")) @@ -1012,7 +1074,7 @@ public void should_use_priority() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/goods/delete", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/goods/delete", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/goods/delete")) @@ -1048,7 +1110,7 @@ public void should_match_multiple_paths_with_catch_all() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test/toot", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test/toot", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/test/toot")) @@ -1099,29 +1161,30 @@ public void should_fix_issue_271() }, }; - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/modules/Test", 200, "Hello from Laura")) + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/modules/Test", HttpStatusCode.OK, "Hello from Laura")) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunning()) .When(x => _steps.WhenIGetUrlOnTheApiGateway("/api/v1/modules/Test")) .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); - } + } - private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody) + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, HttpStatusCode statusCode, string responseBody) { _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => { - _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value + context.Request.Path.Value : context.Request.Path.Value; + _downstreamQuery = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : string.Empty; if (_downstreamPath != basePath) { - context.Response.StatusCode = statusCode; + context.Response.StatusCode = (int)statusCode; await context.Response.WriteAsync("downstream path didnt match base path"); } else { - context.Response.StatusCode = statusCode; + context.Response.StatusCode = (int)statusCode; await context.Response.WriteAsync(responseBody); } }); @@ -1131,11 +1194,28 @@ internal void ThenTheDownstreamUrlPathShouldBe(string expectedDownstreamPath) { _downstreamPath.ShouldBe(expectedDownstreamPath); } - - public void Dispose() + + internal void ThenTheDownstreamUrlQueryStringShouldBe(string expectedQueryString) { - _serviceHandler.Dispose(); - _steps.Dispose(); - } + _downstreamQuery.ShouldBe(expectedQueryString); + } + + private FileConfiguration GivenDefaultConfiguration(int port, string upstream, string downstream) => new() + { + Routes = new() + { + new() + { + DownstreamPathTemplate = downstream, + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = + { + new("localhost", port), + }, + UpstreamPathTemplate = upstream, + UpstreamHttpMethod = [HttpMethods.Get], + }, + }, + }; } } diff --git a/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs index e41c80261..4028d456b 100644 --- a/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/UpstreamTemplatePatternCreatorTests.cs @@ -1,6 +1,6 @@ using Ocelot.Configuration.Creator; using Ocelot.Configuration.File; -using Ocelot.Values; +using Ocelot.Values; namespace Ocelot.UnitTests.Configuration { @@ -8,7 +8,8 @@ public class UpstreamTemplatePatternCreatorTests { private FileRoute _fileRoute; private readonly UpstreamTemplatePatternCreator _creator; - private UpstreamPathTemplate _result; + private UpstreamPathTemplate _result; + private const string MatchEverything = UpstreamTemplatePatternCreator.RegExMatchZeroOrMoreOfEverything; public UpstreamTemplatePatternCreatorTests() { @@ -42,7 +43,7 @@ public void should_use_re_route_priority() this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^(?i)/orders/.+$")) + .Then(x => x.ThenTheFollowingIsReturned($"^(?i)/orders(?:|/{MatchEverything})$")) .And(x => ThenThePriorityIs(0)) .BDDfy(); } @@ -74,7 +75,7 @@ public void should_set_upstream_template_pattern_to_ignore_case_sensitivity() this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^(?i)/PRODUCTS/.+$")) + .Then(x => x.ThenTheFollowingIsReturned($"^(?i)/PRODUCTS(?:|/{MatchEverything})$")) .And(x => ThenThePriorityIs(1)) .BDDfy(); } @@ -105,7 +106,7 @@ public void should_set_upstream_template_pattern_to_respect_case_sensitivity() }; this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^/PRODUCTS/.+$")) + .Then(x => x.ThenTheFollowingIsReturned($"^/PRODUCTS(?:|/{MatchEverything})$")) .And(x => ThenThePriorityIs(1)) .BDDfy(); } @@ -121,7 +122,7 @@ public void should_create_template_pattern_that_matches_anything_to_end_of_strin this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^/api/products/.+$")) + .Then(x => x.ThenTheFollowingIsReturned($"^/api/products(?:|/{MatchEverything})$")) .And(x => ThenThePriorityIs(1)) .BDDfy(); } @@ -137,7 +138,7 @@ public void should_create_template_pattern_that_matches_more_than_one_placeholde this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^/api/products/[^/]+/variants/.+$")) + .Then(x => x.ThenTheFollowingIsReturned($"^/api/products/[^/]+/variants(?:|/{MatchEverything})$")) .And(x => ThenThePriorityIs(1)) .BDDfy(); } @@ -214,7 +215,7 @@ public void should_create_template_pattern_that_matches_query_string() this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^(?i)/api/subscriptions/[^/]+/updates\\?unitId=.+$")) + .Then(x => x.ThenTheFollowingIsReturned($"^(?i)/api/subscriptions/[^/]+/updates(|\\?)unitId={MatchEverything}$")) .And(x => ThenThePriorityIs(1)) .BDDfy(); } @@ -229,7 +230,7 @@ public void should_create_template_pattern_that_matches_query_string_with_multip this.Given(x => x.GivenTheFollowingFileRoute(fileRoute)) .When(x => x.WhenICreateTheTemplatePattern()) - .Then(x => x.ThenTheFollowingIsReturned("^(?i)/api/subscriptions/[^/]+/updates\\?unitId=.+&productId=.+$")) + .Then(x => x.ThenTheFollowingIsReturned($"^(?i)/api/subscriptions/[^/]+/updates(|\\?)unitId={MatchEverything}&productId={MatchEverything}$")) .And(x => ThenThePriorityIs(1)) .BDDfy(); } diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinderTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinderTests.cs index 9e51a7233..606b63714 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinderTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinderTests.cs @@ -349,6 +349,60 @@ public void can_match_down_stream_url_with_downstream_template_with_place_holder .BDDfy(); } + [Fact] + [Trait("Bug", "748")] + public void check_for_placeholder_at_end_of_template() + { + var expectedTemplates = new List + { + new("{testId}", string.Empty), + }; + this.Given(x => x.GivenIHaveAUpstreamPath("/upstream/test/")) + .And(x => x.GivenIHaveAnUpstreamUrlTemplate("/upstream/test/{testId}")) + .When(x => x.WhenIFindTheUrlVariableNamesAndValues()) + .And(x => x.ThenTheTemplatesVariablesAre(expectedTemplates)) + .BDDfy(); + } + + [Theory] + [Trait("Bug", "748")] + [InlineData("/api/invoices/{url}", "/api/invoices/123", "{url}", "123")] + [InlineData("/api/invoices/{url}", "/api/invoices/", "{url}", "")] + [InlineData("/api/invoices/{url}", "/api/invoices", "{url}", "")] + [InlineData("/api/{version}/invoices/", "/api/v1/invoices/", "{version}", "v1")] + public void should_fix_issue_748(string upstreamTemplate, string requestURL, string placeholderName, string placeholderValue) + { + var expectedTemplates = new List + { + new(placeholderName, placeholderValue), + }; + this.Given(x => x.GivenIHaveAUpstreamPath(requestURL)) + .And(x => x.GivenIHaveAnUpstreamUrlTemplate(upstreamTemplate)) + .When(x => x.WhenIFindTheUrlVariableNamesAndValues()) + .And(x => x.ThenTheTemplatesVariablesAre(expectedTemplates)) + .BDDfy(); + } + + [Theory] + [Trait("Bug", "748")] + [InlineData("/api/{version}/invoices/{url}", "/api/v1/invoices/123", "{version}", "v1", "{url}", "123")] + [InlineData("/api/{version}/invoices/{url}", "/api/v1/invoices/", "{version}", "v1", "{url}", "")] + [InlineData("/api/invoices/{url}?{query}", "/api/invoices/test?query=1", "{url}", "test", "{query}", "query=1")] + [InlineData("/api/invoices/{url}?{query}", "/api/invoices/?query=1", "{url}", "", "{query}", "query=1")] + public void should_resolve_catchall_at_end_with_middle_placeholder(string upstreamTemplate, string requestURL, string placeholderName, string placeholderValue, string catchallName, string catchallValue) + { + var expectedTemplates = new List + { + new(placeholderName, placeholderValue), + new(catchallName, catchallValue), + }; + this.Given(x => x.GivenIHaveAUpstreamPath(requestURL)) + .And(x => x.GivenIHaveAnUpstreamUrlTemplate(upstreamTemplate)) + .When(x => x.WhenIFindTheUrlVariableNamesAndValues()) + .And(x => x.ThenTheTemplatesVariablesAre(expectedTemplates)) + .BDDfy(); + } + private void ThenTheTemplatesVariablesAre(List expectedResults) { foreach (var expectedResult in expectedResults) diff --git a/test/Ocelot.UnitTests/DownstreamUrlCreator/DownstreamUrlCreatorMiddlewareTests.cs b/test/Ocelot.UnitTests/DownstreamUrlCreator/DownstreamUrlCreatorMiddlewareTests.cs index febe8748c..c6529e33d 100644 --- a/test/Ocelot.UnitTests/DownstreamUrlCreator/DownstreamUrlCreatorMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/DownstreamUrlCreator/DownstreamUrlCreatorMiddlewareTests.cs @@ -501,6 +501,48 @@ public void Should_forward_query_parameters_without_duplicates(string everything .BDDfy(); } + [Theory] + [Trait("Bug", "748")] + [InlineData("/test/{version}/{url}", "/api/{version}/test/{url}", "/test/v1/123", "{url}", "123", "/api/v1/test/123", "")] + [InlineData("/test/{version}/{url}", "/api/{version}/test/{url}", "/test/v1/123?query=1", "{url}", "123", "/api/v1/test/123?query=1", "?query=1")] + [InlineData("/test/{version}/{url}", "/api/{version}/test/{url}", "/test/v1/?query=1", "{url}", "", "/api/v1/test/?query=1", "?query=1")] + [InlineData("/test/{version}/{url}", "/api/{version}/test/{url}", "/test/v1?query=1", "{url}", "", "/api/v1/test?query=1", "?query=1")] + [InlineData("/test/{version}/{url}", "/api/{version}/test/{url}", "/test/v1/", "{url}", "", "/api/v1/test/", "")] + [InlineData("/test/{version}/{url}", "/api/{version}/test/{url}", "/test/v1", "{url}", "", "/api/v1/test", "")] + public void should_fix_issue_748(string upstreamTemplate, string downstreamTemplate, string requestURL, string placeholderName, string placeholderValue, string downstreamURI, string queryString) + { + var methods = new List { "Get" }; + var downstreamRoute = new DownstreamRouteBuilder() + .WithUpstreamPathTemplate(new UpstreamPathTemplateBuilder() + .WithOriginalValue(upstreamTemplate).Build()) + .WithDownstreamPathTemplate(downstreamTemplate) + .WithUpstreamHttpMethod(methods) + .WithDownstreamScheme(Uri.UriSchemeHttp) + .Build(); + + var config = new ServiceProviderConfigurationBuilder() + .Build(); + + this.Given(x => x.GivenTheDownStreamRouteIs( + new DownstreamRouteHolder( + new List + { + new(placeholderName, placeholderValue), + new("{version}", "v1"), + }, + new RouteBuilder() + .WithDownstreamRoute(downstreamRoute) + .WithUpstreamHttpMethod(new List { "Get" }) + .Build()))) + .And(x => x.GivenTheDownstreamRequestUriIs("http://localhost:5000" + requestURL)) + .And(x => GivenTheServiceProviderConfigIs(config)) + .And(x => x.GivenTheUrlReplacerWillReturn(downstreamURI)) + .When(x => x.WhenICallTheMiddleware()) + .Then(x => x.ThenTheDownstreamRequestUriIs("http://localhost:5000" + downstreamURI)) + .And(x => ThenTheQueryStringIs(queryString)) + .BDDfy(); + } + private void GivenTheServiceProviderConfigIs(ServiceProviderConfiguration config) { var configuration = new InternalConfiguration(null, null, config, null, null, null, null, null, null); From c9510b1c6aed7902e681978f5104bd2780f2f5dd Mon Sep 17 00:00:00 2001 From: Igor <54959243+MayorSheFF@users.noreply.github.com> Date: Mon, 5 Feb 2024 19:39:42 +0100 Subject: [PATCH 7/9] #740 #1580 Support multiple authentication schemes in one route (#1870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #1580. Added an opportunity to use several authentication provider keys. * Update src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs Co-authored-by: Raynald Messié * #1580. Replaced AuthenticationProviderKeys type from the list to the array. * #1580. Added a doc how to use AuthenticationProviderKeys in a Route. * #1580. Amended the description how to use AuthenticationProviderKeys in a Route. * #1580. Added an opportunity to use several authentication provider keys. * Update src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs Co-authored-by: Raynald Messié * #1580. Replaced AuthenticationProviderKeys type from the list to the array. * #1580. Added a doc how to use AuthenticationProviderKeys in a Route. * #1580. Amended the description how to use AuthenticationProviderKeys in a Route. * Quick review * #1580. Implemented review points. * #1580. Initialized result with AuthenticateResult.NoResult(). * #1580. Added @ggnaegi suggestions. * #1580. Brought back the idea not to allocate AuthenticateResult instance. * quick review * Return Auth result of the last key in the collection * review unit tests * Enable parallelization of unit tests * Fix messages * Disable parallelization for PollyQoSProviderTests * Switch off unstable test * Re-enable parallelization & Isolate unstable test * Reflection issue in middleware base: remove getting Type object * Switch off unstable test * Clean code * Make MiddlewareName as public property * Code review by @RaynaldM * AuthenticationMiddleware: Line & Branch coverage -> 100% * AuthenticationOptionsCreator: coverage -> 100% * Remove private helpers with one reference * RouteOptionsCreator: coverage -> 100% * FileAuthenticationOptions: Refactor ToString method * FileConfigurationFluentValidator: coverage -> 100% * RouteFluentValidator: Branch coverage -> 100% * TODO and Skip unstable test * Move acceptance tests to the separate folder * Review and refactor acceptance tests * Add AuthenticationSteps class. Choose inheritance over composition: less code * Add 'GivenIHaveATokenWithScope' to 'AuthenticationSteps' * Temporarily disable 'Should_timeout_per_default_after_90_seconds' test * Add CreateIdentityServer method * Add draft test * Update route validator to support multiple auth schemes * Acceptance tests * Revert "TODO and Skip unstable test" This reverts commit 1ec8564691ebadf5b85161b6430beeac84dfb8d0. * Revert "Make MiddlewareName as public property" This reverts commit 6f50c760327b795ae90c45536bd495fc40f99f80. * Revert "Reflection issue in middleware base: remove getting Type object" * Clean up * Isolate unstable test * Mark old property as `Obsolete` * a tiny little bit of cleanup * Handling cases when principal or identity are null * Update Authentication feature docs * Convert back to block scoped namespace --------- Co-authored-by: Igor Polishchuk Co-authored-by: Raman Maksimchuk Co-authored-by: Raynald Messié Co-authored-by: Igor Polishchuk Co-authored-by: Guillaume Gnaegi <58469901+ggnaegi@users.noreply.github.com> --- docs/features/authentication.rst | 126 +- .../Middleware/AuthenticationMiddleware.cs | 88 +- .../AuthenticationMiddlewareExtensions.cs | 11 + ...nticationMiddlewareMiddlewareExtensions.cs | 12 - .../Configuration/AuthenticationOptions.cs | 63 +- .../Builder/AuthenticationOptionsBuilder.cs | 12 +- .../Creator/AuthenticationOptionsCreator.cs | 6 +- .../Creator/RouteOptionsCreator.cs | 30 +- .../File/FileAuthenticationOptions.cs | 53 +- .../FileConfigurationFluentValidator.cs | 15 +- .../Validator/RouteFluentValidator.cs | 23 +- src/Ocelot/Values/DownstreamPathTemplate.cs | 2 + .../Authentication/AuthenticationSteps.cs | 180 ++ .../Authentication/AuthenticationTests.cs | 130 ++ .../MultipleAuthSchemesFeatureTests.cs | 159 ++ .../AuthenticationTests.cs | 377 ---- .../AuthorizationTests.cs | 76 +- test/Ocelot.AcceptanceTests/PollyQoSTests.cs | 344 ++-- test/Ocelot.AcceptanceTests/Steps.cs | 98 +- .../AuthenticationMiddlewareTests.cs | 263 ++- .../AuthenticationOptionsCreatorTests.cs | 99 +- .../Configuration/RouteOptionsCreatorTests.cs | 211 ++- .../FileConfigurationFluentValidatorTests.cs | 1518 +++++------------ .../Validation/RouteFluentValidatorTests.cs | 28 +- .../Polly/PollyQoSProviderTests.cs | 3 +- 25 files changed, 1873 insertions(+), 2054 deletions(-) create mode 100644 src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareExtensions.cs delete mode 100644 src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareMiddlewareExtensions.cs create mode 100644 test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs create mode 100644 test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs create mode 100644 test/Ocelot.AcceptanceTests/Authentication/MultipleAuthSchemesFeatureTests.cs delete mode 100644 test/Ocelot.AcceptanceTests/AuthenticationTests.cs diff --git a/docs/features/authentication.rst b/docs/features/authentication.rst index 3b2398e8d..18fb2da97 100644 --- a/docs/features/authentication.rst +++ b/docs/features/authentication.rst @@ -2,36 +2,91 @@ Authentication ============== In order to authenticate Routes and subsequently use any of Ocelot's claims based features such as authorization or modifying the request with values from the token, -users must register authentication services in their **Startup.cs** as usual but they provide a scheme (authentication provider key) with each registration e.g. +users must register authentication services in their **Startup.cs** as usual but they provide `a scheme `_ +(authentication provider key) with each registration e.g. .. code-block:: csharp public void ConfigureServices(IServiceCollection services) { - var authenticationProviderKey = "TestKey"; + const string AuthenticationProviderKey = "MyKey"; services .AddAuthentication() - .AddJwtBearer(authenticationProviderKey, - options => { /* custom auth-setup */ }); + .AddJwtBearer(AuthenticationProviderKey, options => + { + // Custom Authentication setup via options initialization + }); } -In this example "**TestKey**" is the scheme that this provider has been registered with. We then map this to a Route in the configuration e.g. +In this example ``MyKey`` is `the scheme `_ that this provider has been registered with. +We then map this to a Route in the configuration using the following `AuthenticationOptions `_ properties: + +* ``AuthenticationProviderKey`` is a string object, obsolete. [#f1]_ This is legacy definition when you define :ref:`authentication-single` (scheme). +* ``AuthenticationProviderKeys`` is an array of strings, the recommended definition of :ref:`authentication-multiple` feature. + +.. authentication-single: + +Single Key [#f1]_ +----------------- + + | Property: ``AuthenticationOptions.AuthenticationProviderKey`` + +We map authentication provider to a Route in the configuration e.g. .. code-block:: json - "Routes": [{ - "AuthenticationOptions": { - "AuthenticationProviderKey": "TestKey", - "AllowedScopes": [] - } - }] + "AuthenticationOptions": { + "AuthenticationProviderKey": "MyKey", + "AllowedScopes": [] + } -When Ocelot runs it will look at this Routes ``AuthenticationOptions.AuthenticationProviderKey`` and check that there is an authentication provider registered with the given key. +When Ocelot runs it will look at this Routes ``AuthenticationProviderKey`` and check that there is an authentication provider registered with the given key. If there isn't then Ocelot will not start up. If there is then the Route will use that provider when it executes. If a Route is authenticated, Ocelot will invoke whatever scheme is associated with it while executing the authentication middleware. If the request fails authentication, Ocelot returns a HTTP status code `401 Unauthorized `_. +.. authentication-multiple: + +Multiple Authentication Schemes [#f2]_ +-------------------------------------- + + | Property: ``AuthenticationOptions.AuthenticationProviderKeys`` + +In real world of ASP.NET, apps may need to support multiple types of authentication by single Ocelot app instance. +To register `multiple authentication schemes `_ +(authentication provider keys) for each appropriate authentication provider, use and develop this abstract configuration of two or more schemes: + +.. code-block:: csharp + + public void ConfigureServices(IServiceCollection services) + { + const string DefaultScheme = JwtBearerDefaults.AuthenticationScheme; // Bearer + services.AddAuthentication() + .AddJwtBearer(DefaultScheme, options => { /* JWT setup */ }) + // AddJwtBearer, AddCookie, AddIdentityServerAuthentication etc. + .AddMyProvider("MyKey", options => { /* Custom auth setup */ }); + } + +In this example, the schemes ``MyKey`` and ``Bearer`` represent the keys which these providers have been registered with. +We then map these schemes to a Route in the configuration, as shown below + +.. code-block:: json + + "AuthenticationOptions": { + "AuthenticationProviderKeys": [ "Bearer", "MyKey" ] // The order matters! + "AllowedScopes": [] + } + +Afterward, Ocelot applies all steps that are specified for ``AuthenticationProviderKey`` as :ref:`authentication-single`. + +**Note** that the order of the keys in an array definition does matter! We use a "First One Wins" authentication strategy. + +Finally, we would say that registering providers, initializing options, forwarding authentication artifacts can be a "real" coding challenge. +If you're stuck or don't know what to do, just find inspiration in our `acceptance tests `_ +(currently for `Identity Server 4 `_ only). +We would appreciate any new PRs to add extra acceptance tests for your custom scenarios with `multiple authentication schemes `__. [#f2]_ + JWT Tokens ---------- @@ -41,7 +96,7 @@ If you want to authenticate using JWT tokens maybe from a provider like `Auth0 < public void ConfigureServices(IServiceCollection services) { - var authenticationProviderKey = "TestKey"; + var authenticationProviderKey = "MyKey"; services .AddAuthentication() .AddJwtBearer(authenticationProviderKey, options => @@ -56,12 +111,16 @@ Then map the authentication provider key to a Route in your configuration e.g. .. code-block:: json - "Routes": [{ - "AuthenticationOptions": { - "AuthenticationProviderKey": "TestKey", - "AllowedScopes": [] - } - }] + "AuthenticationOptions": { + "AuthenticationProviderKeys": [ "MyKey" ], + "AllowedScopes": [] + } + +Docs +^^^^ + +* Microsoft Learn: `Authentication and authorization in minimal APIs `_ +* Andrew Lock | .NET Escapades: `A look behind the JWT bearer authentication middleware in ASP.NET Core `_ Identity Server Bearer Tokens ----------------------------- @@ -73,7 +132,7 @@ If you don't understand how to do this, please consult the IdentityServer `docum public void ConfigureServices(IServiceCollection services) { - var authenticationProviderKey = "TestKey"; + var authenticationProviderKey = "MyKey"; Action options = (opt) => { opt.Authority = "https://whereyouridentityserverlives.com"; @@ -89,12 +148,10 @@ Then map the authentication provider key to a Route in your configuration e.g. .. code-block:: json - "Routes": [{ - "AuthenticationOptions": { - "AuthenticationProviderKey": "TestKey", - "AllowedScopes": [] - } - }] + "AuthenticationOptions": { + "AuthenticationProviderKeys": [ "MyKey" ], + "AllowedScopes": [] + } Auth0 by Okta ------------- @@ -137,8 +194,21 @@ If you add scopes to **AllowedScopes**, Ocelot will get all the user claims (fro This is a way to restrict access to a Route on a per scope basis. -More identity providers ------------------------ +Links +----- + +* Microsoft Learn: `Overview of ASP.NET Core authentication `_ +* Microsoft Learn: `Authorize with a specific scheme in ASP.NET Core `_ +* Microsoft Learn: `Policy schemes in ASP.NET Core `_ +* Microsoft .NET Blog: `ASP.NET Core Authentication with IdentityServer4 `_ + +Future +------ We invite you to add more examples, if you have integrated with other identity providers and the integration solution is working. Please, open `Show and tell `_ discussion in the repository. + +"""" + +.. [#f1] Use the ``AuthenticationProviderKeys`` property instead of ``AuthenticationProviderKey`` one. We supports this obsolete property because of backward compatibility and allowing migrations. In future releases the property can be removed as a breaking change. +.. [#f2] `Multiple authentication schemes `__ feature was requested in issues `740 `_, `1580 `_ and delivered as a part of `23.0 `_ release. diff --git a/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs b/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs index 843c3dac5..22fa1dca0 100644 --- a/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs +++ b/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs @@ -6,12 +6,11 @@ namespace Ocelot.Authentication.Middleware { - public class AuthenticationMiddleware : OcelotMiddleware + public sealed class AuthenticationMiddleware : OcelotMiddleware { private readonly RequestDelegate _next; - public AuthenticationMiddleware(RequestDelegate next, - IOcelotLoggerFactory loggerFactory) + public AuthenticationMiddleware(RequestDelegate next, IOcelotLoggerFactory loggerFactory) : base(loggerFactory.CreateLogger()) { _next = next; @@ -19,42 +18,81 @@ public class AuthenticationMiddleware : OcelotMiddleware public async Task Invoke(HttpContext httpContext) { + var request = httpContext.Request; + var path = httpContext.Request.Path; var downstreamRoute = httpContext.Items.DownstreamRoute(); - if (httpContext.Request.Method.ToUpper() != "OPTIONS" && IsAuthenticatedRoute(downstreamRoute)) + // reducing nesting, returning early when no authentication is needed. + if (request.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase) || !downstreamRoute.IsAuthenticated) { - Logger.LogInformation(() => $"{httpContext.Request.Path} is an authenticated route. {MiddlewareName} checking if client is authenticated"); + Logger.LogInformation($"No authentication needed for path '{path}'."); + await _next(httpContext); + return; + } - var result = await httpContext.AuthenticateAsync(downstreamRoute.AuthenticationOptions.AuthenticationProviderKey); + Logger.LogInformation(() => $"The path '{path}' is an authenticated route! {MiddlewareName} checking if client is authenticated..."); - httpContext.User = result.Principal; + var result = await AuthenticateAsync(httpContext, downstreamRoute); - if (httpContext.User.Identity.IsAuthenticated) - { - Logger.LogInformation(() => $"Client has been authenticated for {httpContext.Request.Path}"); - await _next.Invoke(httpContext); - } - else - { - var error = new UnauthenticatedError( - $"Request for authenticated route {httpContext.Request.Path} by {httpContext.User.Identity.Name} was unauthenticated"); + if (result.Principal?.Identity == null) + { + SetUnauthenticatedError(httpContext, path, null); + return; + } - Logger.LogWarning(() =>$"Client has NOT been authenticated for {httpContext.Request.Path} and pipeline error set. {error}"); + httpContext.User = result.Principal; - httpContext.Items.SetError(error); - } - } - else + if (httpContext.User.Identity.IsAuthenticated) { - Logger.LogInformation(() => $"No authentication needed for {httpContext.Request.Path}"); - + Logger.LogInformation(() => $"Client has been authenticated for path '{path}' by '{httpContext.User.Identity.AuthenticationType}' scheme."); await _next.Invoke(httpContext); + return; } + + SetUnauthenticatedError(httpContext, path, httpContext.User.Identity.Name); + } + + private void SetUnauthenticatedError(HttpContext httpContext, string path, string userName) + { + var error = new UnauthenticatedError($"Request for authenticated route '{path}' {(string.IsNullOrEmpty(userName) ? "was unauthenticated" : $"by '{userName}' was unauthenticated!")}"); + Logger.LogWarning(() => $"Client has NOT been authenticated for path '{path}' and pipeline error set. {error};"); + httpContext.Items.SetError(error); } - private static bool IsAuthenticatedRoute(DownstreamRoute route) + private async Task AuthenticateAsync(HttpContext context, DownstreamRoute route) { - return route.IsAuthenticated; + var options = route.AuthenticationOptions; + if (!string.IsNullOrWhiteSpace(options.AuthenticationProviderKey)) + { + return await context.AuthenticateAsync(options.AuthenticationProviderKey); + } + + var providerKeys = options.AuthenticationProviderKeys; + if (providerKeys.Length == 0 || providerKeys.All(string.IsNullOrWhiteSpace)) + { + Logger.LogWarning(() => $"Impossible to authenticate client for path '{route.DownstreamPathTemplate}': both {nameof(options.AuthenticationProviderKey)} and {nameof(options.AuthenticationProviderKeys)} are empty but the {nameof(Configuration.AuthenticationOptions)} have defined."); + return AuthenticateResult.NoResult(); + } + + AuthenticateResult result = null; + foreach (var scheme in providerKeys.Where(apk => !string.IsNullOrWhiteSpace(apk))) + { + try + { + result = await context.AuthenticateAsync(scheme); + if (result?.Succeeded == true) + { + return result; + } + } + catch (Exception e) + { + Logger.LogWarning(() => + $"Impossible to authenticate client for path '{route.DownstreamPathTemplate}' and {nameof(options.AuthenticationProviderKey)}:{scheme}. Error: {e.Message}."); + } + } + + return result ?? AuthenticateResult.NoResult(); } } } diff --git a/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareExtensions.cs b/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareExtensions.cs new file mode 100644 index 000000000..d0715e844 --- /dev/null +++ b/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; + +namespace Ocelot.Authentication.Middleware; + +public static class AuthenticationMiddlewareExtensions +{ + public static IApplicationBuilder UseAuthenticationMiddleware(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareMiddlewareExtensions.cs b/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareMiddlewareExtensions.cs deleted file mode 100644 index 3adddff2d..000000000 --- a/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareMiddlewareExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace Ocelot.Authentication.Middleware -{ - public static class AuthenticationMiddlewareMiddlewareExtensions - { - public static IApplicationBuilder UseAuthenticationMiddleware(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} diff --git a/src/Ocelot/Configuration/AuthenticationOptions.cs b/src/Ocelot/Configuration/AuthenticationOptions.cs index c504b1d43..af5cf7273 100644 --- a/src/Ocelot/Configuration/AuthenticationOptions.cs +++ b/src/Ocelot/Configuration/AuthenticationOptions.cs @@ -1,14 +1,51 @@ -namespace Ocelot.Configuration -{ - public class AuthenticationOptions - { - public AuthenticationOptions(List allowedScopes, string authenticationProviderKey) - { - AllowedScopes = allowedScopes; - AuthenticationProviderKey = authenticationProviderKey; - } - - public List AllowedScopes { get; } - public string AuthenticationProviderKey { get; } - } +using Ocelot.Configuration.File; + +namespace Ocelot.Configuration +{ + public sealed class AuthenticationOptions + { + public AuthenticationOptions(List allowedScopes, string authenticationProviderKey) + { + AllowedScopes = allowedScopes; + AuthenticationProviderKey = authenticationProviderKey; + AuthenticationProviderKeys = []; + } + + public AuthenticationOptions(FileAuthenticationOptions from) + { + AllowedScopes = from.AllowedScopes ?? []; + AuthenticationProviderKey = from.AuthenticationProviderKey ?? string.Empty; + AuthenticationProviderKeys = from.AuthenticationProviderKeys ?? []; + } + + public AuthenticationOptions(List allowedScopes, string authenticationProviderKey, + string[] authenticationProviderKeys) + { + AllowedScopes = allowedScopes ?? []; + AuthenticationProviderKey = authenticationProviderKey ?? string.Empty; + AuthenticationProviderKeys = authenticationProviderKeys ?? []; + } + + public List AllowedScopes { get; } + + /// + /// Authentication scheme registered in DI services with appropriate authentication provider. + /// + /// + /// A value of the scheme name. + /// + [Obsolete("Use the " + nameof(AuthenticationProviderKeys) + " property!")] + public string AuthenticationProviderKey { get; } + + /// + /// Multiple authentication schemes registered in DI services with appropriate authentication providers. + /// + /// + /// The order in the collection matters: first successful authentication result wins. + /// + /// + /// An array of values of the scheme names. + /// + public string[] AuthenticationProviderKeys { get; } + } } diff --git a/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs b/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs index bc682afd4..e911908c7 100644 --- a/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs +++ b/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs @@ -4,22 +4,30 @@ public class AuthenticationOptionsBuilder { private List _allowedScopes = new(); private string _authenticationProviderKey; + private string[] _authenticationProviderKeys =[]; public AuthenticationOptionsBuilder WithAllowedScopes(List allowedScopes) { _allowedScopes = allowedScopes; return this; } - + + [Obsolete("Use the " + nameof(WithAuthenticationProviderKeys) + " property!")] public AuthenticationOptionsBuilder WithAuthenticationProviderKey(string authenticationProviderKey) { _authenticationProviderKey = authenticationProviderKey; return this; } + public AuthenticationOptionsBuilder WithAuthenticationProviderKeys(string[] authenticationProviderKeys) + { + _authenticationProviderKeys = authenticationProviderKeys; + return this; + } + public AuthenticationOptions Build() { - return new AuthenticationOptions(_allowedScopes, _authenticationProviderKey); + return new AuthenticationOptions(_allowedScopes, _authenticationProviderKey, _authenticationProviderKeys); } } } diff --git a/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs b/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs index 275d6d90d..d26d39357 100644 --- a/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs @@ -4,9 +4,7 @@ namespace Ocelot.Configuration.Creator { public class AuthenticationOptionsCreator : IAuthenticationOptionsCreator { - public AuthenticationOptions Create(FileRoute route) - { - return new AuthenticationOptions(route.AuthenticationOptions.AllowedScopes, route.AuthenticationOptions.AuthenticationProviderKey); - } + public AuthenticationOptions Create(FileRoute route) + => new(route?.AuthenticationOptions ?? new()); } } diff --git a/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs index 2c2f71315..8e0911e56 100644 --- a/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs @@ -6,30 +6,28 @@ namespace Ocelot.Configuration.Creator public class RouteOptionsCreator : IRouteOptionsCreator { public RouteOptions Create(FileRoute fileRoute) - { - var isAuthenticated = IsAuthenticated(fileRoute); - var isAuthorized = IsAuthorized(fileRoute); - var isCached = IsCached(fileRoute); - var enableRateLimiting = IsEnableRateLimiting(fileRoute); + { + if (fileRoute == null) + { + return new RouteOptionsBuilder().Build(); + } + + var authOpts = fileRoute.AuthenticationOptions; + var isAuthenticated = authOpts != null + && (!string.IsNullOrEmpty(authOpts.AuthenticationProviderKey) + || authOpts.AuthenticationProviderKeys?.Any(k => !string.IsNullOrWhiteSpace(k)) == true); + var isAuthorized = fileRoute.RouteClaimsRequirement?.Any() == true; + var isCached = fileRoute.FileCacheOptions.TtlSeconds > 0; + var enableRateLimiting = fileRoute.RateLimitOptions?.EnableRateLimiting == true; var useServiceDiscovery = !string.IsNullOrEmpty(fileRoute.ServiceName); - var options = new RouteOptionsBuilder() + return new RouteOptionsBuilder() .WithIsAuthenticated(isAuthenticated) .WithIsAuthorized(isAuthorized) .WithIsCached(isCached) .WithRateLimiting(enableRateLimiting) .WithUseServiceDiscovery(useServiceDiscovery) .Build(); - - return options; } - - private static bool IsEnableRateLimiting(FileRoute fileRoute) => fileRoute.RateLimitOptions?.EnableRateLimiting == true; - - private static bool IsAuthenticated(FileRoute fileRoute) => !string.IsNullOrEmpty(fileRoute.AuthenticationOptions?.AuthenticationProviderKey); - - private static bool IsAuthorized(FileRoute fileRoute) => fileRoute.RouteClaimsRequirement?.Count > 0; - - private static bool IsCached(FileRoute fileRoute) => fileRoute.FileCacheOptions.TtlSeconds > 0; } } diff --git a/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs b/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs index eebce214c..24d9b787d 100644 --- a/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs +++ b/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs @@ -1,28 +1,31 @@ -namespace Ocelot.Configuration.File -{ - public class FileAuthenticationOptions - { - public FileAuthenticationOptions() - { - AllowedScopes = new List(); - } +namespace Ocelot.Configuration.File +{ + public sealed class FileAuthenticationOptions + { + public FileAuthenticationOptions() + { + AllowedScopes = []; + AuthenticationProviderKeys = []; + } public FileAuthenticationOptions(FileAuthenticationOptions from) - { - AllowedScopes = new(from.AllowedScopes); - AuthenticationProviderKey = from.AuthenticationProviderKey; - } - - public string AuthenticationProviderKey { get; set; } - public List AllowedScopes { get; set; } - - public override string ToString() - { - var sb = new StringBuilder(); - sb.Append($"{nameof(AuthenticationProviderKey)}:{AuthenticationProviderKey},{nameof(AllowedScopes)}:["); - sb.AppendJoin(',', AllowedScopes); - sb.Append(']'); - return sb.ToString(); - } - } + { + AllowedScopes = [..from.AllowedScopes]; + AuthenticationProviderKey = from.AuthenticationProviderKey; + AuthenticationProviderKeys = from.AuthenticationProviderKeys; + } + + public List AllowedScopes { get; set; } + + [Obsolete("Use the " + nameof(AuthenticationProviderKeys) + " property!")] + public string AuthenticationProviderKey { get; set; } + + public string[] AuthenticationProviderKeys { get; set; } + + public override string ToString() => new StringBuilder() + .Append($"{nameof(AuthenticationProviderKey)}:'{AuthenticationProviderKey}',") + .Append($"{nameof(AuthenticationProviderKeys)}:[{string.Join(',', AuthenticationProviderKeys.Select(x => $"'{x}'"))}],") + .Append($"{nameof(AllowedScopes)}:[{string.Join(',', AllowedScopes.Select(x => $"'{x}'"))}]") + .ToString(); + } } diff --git a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs index 82ed1a9eb..064a4e0c9 100644 --- a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs @@ -66,9 +66,9 @@ private bool HaveServiceDiscoveryProviderRegistered(FileRoute route, FileService private bool HaveServiceDiscoveryProviderRegistered(FileServiceDiscoveryProvider serviceDiscoveryProvider) { - return serviceDiscoveryProvider == null || - serviceDiscoveryProvider?.Type?.ToLower() == Servicefabric || - string.IsNullOrEmpty(serviceDiscoveryProvider.Type) || _serviceDiscoveryFinderDelegates.Any(); + return serviceDiscoveryProvider == null || + Servicefabric.Equals(serviceDiscoveryProvider.Type, StringComparison.InvariantCultureIgnoreCase) || + string.IsNullOrEmpty(serviceDiscoveryProvider.Type) || _serviceDiscoveryFinderDelegates.Any(); } public async Task> IsValid(FileConfiguration configuration) @@ -150,13 +150,10 @@ private static bool IsPlaceholderNotDuplicatedIn(string upstreamPathTemplate) return !duplicate; } - private static bool IsNotDuplicateIn(FileAggregateRoute route, - IEnumerable aggregateRoutes) + private static bool IsNotDuplicateIn(FileAggregateRoute route, IEnumerable aggregateRoutes) { - var matchingRoutes = aggregateRoutes - .Where(r => r.UpstreamPathTemplate == route.UpstreamPathTemplate - && r.UpstreamHost == route.UpstreamHost); - + var matchingRoutes = aggregateRoutes + .Where(r => r.UpstreamPathTemplate == route.UpstreamPathTemplate & r.UpstreamHost == route.UpstreamHost); return matchingRoutes.Count() <= 1; } } diff --git a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs index 61bb4e0f1..f383bf75c 100644 --- a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs @@ -86,18 +86,19 @@ public RouteFluentValidator(IAuthenticationSchemeProvider authenticationSchemePr }); } - private async Task IsSupportedAuthenticationProviders(FileAuthenticationOptions authenticationOptions, CancellationToken cancellationToken) + private async Task IsSupportedAuthenticationProviders(FileAuthenticationOptions options, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(authenticationOptions.AuthenticationProviderKey)) + if (string.IsNullOrEmpty(options.AuthenticationProviderKey) + && options.AuthenticationProviderKeys.Length == 0) { return true; } var schemes = await _authenticationSchemeProvider.GetAllSchemesAsync(); - - var supportedSchemes = schemes.Select(scheme => scheme.Name); - - return supportedSchemes.Contains(authenticationOptions.AuthenticationProviderKey); + var supportedSchemes = schemes.Select(scheme => scheme.Name).ToList(); + var primary = options.AuthenticationProviderKey; + return !string.IsNullOrEmpty(primary) && supportedSchemes.Contains(primary) + || (string.IsNullOrEmpty(primary) && options.AuthenticationProviderKeys.All(supportedSchemes.Contains)); } private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions) @@ -107,17 +108,17 @@ private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions) return false; } - var period = rateLimitOptions.Period; + var period = rateLimitOptions.Period.Trim(); var secondsRegEx = new Regex("^[0-9]+s"); 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 - || daysRegEx.Match(period).Success; + || minutesRegEx.Match(period).Success + || hoursRegEx.Match(period).Success + || daysRegEx.Match(period).Success; } } } diff --git a/src/Ocelot/Values/DownstreamPathTemplate.cs b/src/Ocelot/Values/DownstreamPathTemplate.cs index de2a30b8c..028401331 100644 --- a/src/Ocelot/Values/DownstreamPathTemplate.cs +++ b/src/Ocelot/Values/DownstreamPathTemplate.cs @@ -8,5 +8,7 @@ public DownstreamPathTemplate(string value) } public string Value { get; } + + public override string ToString() => Value ?? string.Empty; } } diff --git a/test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs new file mode 100644 index 000000000..29e1cb286 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs @@ -0,0 +1,180 @@ +using IdentityServer4.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Ocelot.Configuration.File; +using System.Security.Claims; + +namespace Ocelot.AcceptanceTests.Authentication; + +public class AuthenticationSteps : Steps, IDisposable +{ + private readonly ServiceHandler _serviceHandler; + + public AuthenticationSteps() : base() + { + _serviceHandler = new ServiceHandler(); + } + + public override void Dispose() + { + _serviceHandler.Dispose(); + base.Dispose(); + GC.SuppressFinalize(this); + } + + public static ApiResource CreateApiResource( + string apiName, + IEnumerable extraScopes = null) => new() + { + Name = apiName, + Description = $"My {apiName} API", + Enabled = true, + DisplayName = "test", + Scopes = new List(extraScopes ?? Enumerable.Empty()) + { + apiName, + $"{apiName}.readOnly", + }, + ApiSecrets = new List + { + new ("secret".Sha256()), + }, + UserClaims = new List + { + "CustomerId", "LocationId", + }, + }; + + protected static Client CreateClientWithSecret(string clientId, Secret secret, AccessTokenType tokenType = AccessTokenType.Jwt, string[] apiScopes = null) + { + var client = DefaultClient(tokenType, apiScopes); + client.ClientId = clientId ?? "client"; + client.ClientSecrets = new Secret[] { secret }; + return client; + } + + protected static Client DefaultClient(AccessTokenType tokenType = AccessTokenType.Jwt, string[] apiScopes = null) + { + apiScopes ??= ["api"]; + return new() + { + ClientId = "client", + AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, + ClientSecrets = new List { new("secret".Sha256()) }, + AllowedScopes = apiScopes + .Union(apiScopes.Select(x => $"{x}.readOnly")) + .Union(["openid", "offline_access"]) + .ToList(), + AccessTokenType = tokenType, + Enabled = true, + RequireClientSecret = false, + RefreshTokenExpiration = TokenExpiration.Absolute, + }; + } + + public static IWebHostBuilder CreateIdentityServer(string url, AccessTokenType tokenType, string[] apiScopes, Client[] clients) + { + apiScopes ??= ["api"]; + clients ??= [DefaultClient(tokenType, apiScopes)]; + var builder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .ConfigureServices(services => + { + services.AddLogging(); + services.AddIdentityServer() + .AddDeveloperSigningCredential() + .AddInMemoryApiScopes(apiScopes + .Select(apiname => new ApiScope(apiname, apiname.ToUpper()))) + .AddInMemoryApiResources(apiScopes + .Select(x => new { i = Array.IndexOf(apiScopes, x), scope = x }) + .Select(x => CreateApiResource(x.scope, ["openid", "offline_access"]))) + .AddInMemoryClients(clients) + .AddTestUsers( + [ + new() + { + Username = "test", + Password = "test", + SubjectId = "registered|1231231", + Claims = new List + { + new("CustomerId", "123"), + new("LocationId", "321"), + }, + }, + ]); + }) + .Configure(app => + { + app.UseIdentityServer(); + }); + return builder; + } + + internal Task GivenAuthToken(string url, string apiScope) + { + var form = GivenDefaultAuthTokenForm(); + form.RemoveAll(x => x.Key == "scope"); + form.Add(new("scope", apiScope)); + return GivenIHaveATokenWithForm(url, form); + } + + internal Task GivenAuthToken(string url, string apiScope, string client) + { + var form = GivenDefaultAuthTokenForm(); + + form.RemoveAll(x => x.Key == "scope"); + form.Add(new("scope", apiScope)); + + form.RemoveAll(x => x.Key == "client_id"); + form.Add(new("client_id", client)); + + return GivenIHaveATokenWithForm(url, form); + } + + public static FileRoute GivenDefaultAuthRoute(int port, string upstreamHttpMethod = null, string authProviderKey = null) => new() + { + DownstreamPathTemplate = "/", + DownstreamHostAndPorts = + [ + new("localhost", port), + ], + DownstreamScheme = Uri.UriSchemeHttp, + UpstreamPathTemplate = "/", + UpstreamHttpMethod = [upstreamHttpMethod ?? HttpMethods.Get], + AuthenticationOptions = new FileAuthenticationOptions + { + AuthenticationProviderKey = authProviderKey ?? "Test", + }, + }; + + public static FileConfiguration GivenConfiguration(params FileRoute[] routes) + { + var configuration = new FileConfiguration(); + configuration.Routes.AddRange(routes); + return configuration; + } + + protected void GivenThereIsAServiceRunningOn(int port, HttpStatusCode statusCode, string responseBody) + { + var url = DownstreamServiceUrl(port); + GivenThereIsAServiceRunningOn(url, statusCode, responseBody); + } + + protected void GivenThereIsAServiceRunningOn(string url, HttpStatusCode statusCode, string responseBody) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + context.Response.StatusCode = (int)statusCode; + await context.Response.WriteAsync(responseBody); + }); + } + + protected static string DownstreamServiceUrl(int port) => string.Concat("http://localhost:", port); +} diff --git a/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs new file mode 100644 index 000000000..111cd3afe --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs @@ -0,0 +1,130 @@ +using IdentityServer4.AccessTokenValidation; +using IdentityServer4.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +namespace Ocelot.AcceptanceTests.Authentication +{ + public sealed class AuthenticationTests : AuthenticationSteps, IDisposable + { + private IWebHost _identityServerBuilder; + private readonly string _identityServerRootUrl; + private readonly Action _options; + + public AuthenticationTests() + { + var identityServerPort = PortFinder.GetRandomPort(); + _identityServerRootUrl = $"http://localhost:{identityServerPort}"; + _options = o => + { + o.Authority = _identityServerRootUrl; + o.ApiName = "api"; + o.RequireHttpsMetadata = false; + o.SupportedTokens = SupportedTokens.Both; + o.ApiSecret = "secret"; + }; + } + + [Fact] + public void Should_return_401_using_identity_server_access_token() + { + var port = PortFinder.GetRandomPort(); + var route = GivenDefaultAuthRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt)) + .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.Created, string.Empty)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenThePostHasContent("postContent")) + .When(x => WhenIPostUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized)) + .BDDfy(); + } + + [Fact] + public void Should_return_response_200_using_identity_server() + { + var port = PortFinder.GetRandomPort(); + var route = GivenDefaultAuthRoute(port); + var configuration = GivenConfiguration(route); + this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt)) + .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.OK, "Hello from Laura")) + .And(x => GivenIHaveAToken(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void Should_return_response_401_using_identity_server_with_token_requested_for_other_api() + { + var port = PortFinder.GetRandomPort(); + var route = GivenDefaultAuthRoute(port); + var configuration = GivenConfiguration(route); + this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt)) + .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.OK, "Hello from Laura")) + .And(x => GivenAuthToken(_identityServerRootUrl, "api2")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized)) + .BDDfy(); + } + + [Fact] + public void Should_return_201_using_identity_server_access_token() + { + var port = PortFinder.GetRandomPort(); + var route = GivenDefaultAuthRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt)) + .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.Created, string.Empty)) + .And(x => GivenIHaveAToken(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .And(x => GivenThePostHasContent("postContent")) + .When(x => WhenIPostUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Created)) + .BDDfy(); + } + + [Fact] + public void Should_return_201_using_identity_server_reference_token() + { + var port = PortFinder.GetRandomPort(); + var route = GivenDefaultAuthRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Reference)) + .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.Created, string.Empty)) + .And(x => GivenIHaveAToken(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .And(x => GivenThePostHasContent("postContent")) + .When(x => WhenIPostUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Created)) + .BDDfy(); + } + + private void GivenThereIsAnIdentityServerOn(string url, AccessTokenType tokenType) + { + var scopes = new string[] { "api", "api2" }; + _identityServerBuilder = CreateIdentityServer(url, tokenType, scopes, null) + .Build(); + _identityServerBuilder.Start(); + VerifyIdentityServerStarted(url); + } + + public override void Dispose() + { + _identityServerBuilder?.Dispose(); + base.Dispose(); + } + } +} diff --git a/test/Ocelot.AcceptanceTests/Authentication/MultipleAuthSchemesFeatureTests.cs b/test/Ocelot.AcceptanceTests/Authentication/MultipleAuthSchemesFeatureTests.cs new file mode 100644 index 000000000..204441497 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Authentication/MultipleAuthSchemesFeatureTests.cs @@ -0,0 +1,159 @@ +using IdentityServer4.AccessTokenValidation; +using IdentityServer4.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Ocelot.DependencyInjection; +using System.Net.Http.Headers; + +namespace Ocelot.AcceptanceTests.Authentication; + +[Trait("PR", "1870")] +[Trait("Issues", "740 1580")] +public sealed class MultipleAuthSchemesFeatureTests : AuthenticationSteps, IDisposable +{ + private IWebHost[] _identityServers; + private string[] _identityServerUrls; + private BearerToken[] _tokens; + + public MultipleAuthSchemesFeatureTests() : base() + { + _identityServers = []; + _identityServerUrls = []; + _tokens = []; + } + + public override void Dispose() + { + foreach (var server in _identityServers) + { + server.Dispose(); + } + + base.Dispose(); + } + + private MultipleAuthSchemesFeatureTests Setup(int totalSchemes) + { + _identityServers = new IWebHost[totalSchemes]; + _identityServerUrls = new string[totalSchemes]; + _tokens = new BearerToken[totalSchemes]; + return this; + } + + [Theory] + [InlineData("Test1", "Test2")] // with multiple schemes + [InlineData(IdentityServerAuthenticationDefaults.AuthenticationScheme, "Test")] // with default scheme + [InlineData("Test", IdentityServerAuthenticationDefaults.AuthenticationScheme)] // with default scheme + public void Should_authenticate_using_identity_server_with_multiple_schemes(string scheme1, string scheme2) + { + var port = PortFinder.GetRandomPort(); + var route = GivenDefaultAuthRoute(port, authProviderKey: string.Empty); + var authSchemes = new string[] { scheme1, scheme2 }; + route.AuthenticationOptions.AuthenticationProviderKeys = authSchemes; + var configuration = GivenConfiguration(route); + var responseBody = nameof(Should_authenticate_using_identity_server_with_multiple_schemes); + + this.Given(x => GivenThereIsAServiceRunningOn(port, HttpStatusCode.OK, responseBody)) + .And(x => Setup(authSchemes.Length) + .GivenIdentityServerWithScopes(0, "invalid", "unknown") + .GivenIdentityServerWithScopes(1, "api1", "api2")) + .And(x => GivenIHaveTokenWithScope(0, "invalid")) // authentication should fail because of invalid scope + .And(x => GivenIHaveTokenWithScope(1, "api2")) // authentication should succeed + + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithIdentityServerAuthSchemes("api2", authSchemes)) + .And(x => GivenIHaveAddedAllAuthHeaders(authSchemes)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(responseBody)) + .BDDfy(); + } + + private MultipleAuthSchemesFeatureTests GivenIdentityServerWithScopes(int index, params string[] scopes) + { + var tokenType = AccessTokenType.Jwt; + string url = _identityServerUrls[index] = $"http://localhost:{PortFinder.GetRandomPort()}"; + var clients = new Client[] { DefaultClient(tokenType, scopes) }; + var builder = CreateIdentityServer(url, tokenType, scopes, clients); + + var server = _identityServers[index] = builder.Build(); + server.Start(); + VerifyIdentityServerStarted(url); + return this; + } + + private async Task GivenIHaveTokenWithScope(int index, string scope) + { + string url = _identityServerUrls[index]; + _tokens[index] = await GivenAuthToken(url, scope); + } + + private async Task GivenIHaveExpiredTokenWithScope(string url, string scope, int index) + { + _tokens[index] = await GivenAuthToken(url, scope, "expired"); + } + + private void GivenIHaveAddedAllAuthHeaders(string[] schemes) + { + // Assume default scheme token is attached as "Authorization" header, for example "Bearer" + // But default authentication setup should be ignored in multiple schemes scenario + _ocelotClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "failed"); + + for (int i = 0; i < schemes.Length && i < _tokens.Length; i++) + { + var token = _tokens[i]; + var header = AuthHeaderName(schemes[i]); + var hvalue = new AuthenticationHeaderValue(token.TokenType, token.AccessToken); + GivenIAddAHeader(header, hvalue.ToString()); + } + } + + private static string AuthHeaderName(string scheme) => $"Oc-{HeaderNames.Authorization}-{scheme}"; + + private void GivenOcelotIsRunningWithIdentityServerAuthSchemes(string validScope, params string[] schemes) + { + const string DefaultScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme; + GivenOcelotIsRunningWithServices(services => + { + services.AddOcelot(); + var auth = services + .AddAuthentication(options => + { + options.DefaultScheme = "MultipleSchemes"; + options.DefaultChallengeScheme = "MultipleSchemes"; + }); + for (int i = 0; i < schemes.Length; i++) + { + var scheme = schemes[i]; + var identityServerUrl = _identityServerUrls[i]; + auth.AddIdentityServerAuthentication(scheme, o => + { + o.Authority = identityServerUrl; + o.ApiName = validScope; + o.ApiSecret = "secret"; + o.RequireHttpsMetadata = false; + o.SupportedTokens = SupportedTokens.Both; + + // TODO TokenRetriever ? + o.ForwardDefaultSelector = (context) => + { + var headers = context.Request.Headers; + var name = AuthHeaderName(scheme); + if (headers.ContainsKey(name)) + { + // Redirect to default authentication handler which is (JwtAuthHandler) aka (Bearer) + headers[HeaderNames.Authorization] = headers[name]; + return scheme; + } + + // Something wrong with the setup: no headers, no tokens. + // Redirect to default scheme to read token from default header + return DefaultScheme; + }; + }); + } + }); + } +} diff --git a/test/Ocelot.AcceptanceTests/AuthenticationTests.cs b/test/Ocelot.AcceptanceTests/AuthenticationTests.cs deleted file mode 100644 index 35329847f..000000000 --- a/test/Ocelot.AcceptanceTests/AuthenticationTests.cs +++ /dev/null @@ -1,377 +0,0 @@ -using IdentityServer4.AccessTokenValidation; -using IdentityServer4.Models; -using IdentityServer4.Test; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Ocelot.Configuration.File; -using System.Security.Claims; - -namespace Ocelot.AcceptanceTests -{ - public class AuthenticationTests : IDisposable - { - private readonly Steps _steps; - private IWebHost _identityServerBuilder; - private readonly string _identityServerRootUrl; - private readonly string _downstreamServicePath = "/"; - private readonly string _downstreamServiceHost = "localhost"; - private readonly string _downstreamServiceScheme = "http"; - private readonly string _downstreamServiceUrl = "http://localhost:"; - private readonly Action _options; - private readonly ServiceHandler _serviceHandler; - - public AuthenticationTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - var identityServerPort = PortFinder.GetRandomPort(); - _identityServerRootUrl = $"http://localhost:{identityServerPort}"; - _options = o => - { - o.Authority = _identityServerRootUrl; - o.ApiName = "api"; - o.RequireHttpsMetadata = false; - o.SupportedTokens = SupportedTokens.Both; - o.ApiSecret = "secret"; - }; - } - - [Fact] - public void should_return_401_using_identity_server_access_token() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = _downstreamServicePath, - DownstreamHostAndPorts = new List - { - new() - { - Host =_downstreamServiceHost, - Port = port, - }, - }, - DownstreamScheme = _downstreamServiceScheme, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Post" }, - AuthenticationOptions = new FileAuthenticationOptions - { - AuthenticationProviderKey = "Test", - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt)) - .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 201, string.Empty)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenThePostHasContent("postContent")) - .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized)) - .BDDfy(); - } - - [Fact] - public void should_return_response_200_using_identity_server() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = _downstreamServicePath, - DownstreamHostAndPorts = new List - { - new() - { - Host =_downstreamServiceHost, - Port = port, - }, - }, - DownstreamScheme = _downstreamServiceScheme, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - AuthenticationOptions = new FileAuthenticationOptions - { - AuthenticationProviderKey = "Test", - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt)) - .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void should_return_response_401_using_identity_server_with_token_requested_for_other_api() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = _downstreamServicePath, - DownstreamHostAndPorts = new List - { - new() - { - Host =_downstreamServiceHost, - Port = port, - }, - }, - DownstreamScheme = _downstreamServiceScheme, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Get" }, - AuthenticationOptions = new FileAuthenticationOptions - { - AuthenticationProviderKey = "Test", - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt)) - .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenIHaveATokenForApi2(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized)) - .BDDfy(); - } - - [Fact] - public void should_return_201_using_identity_server_access_token() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = _downstreamServicePath, - DownstreamHostAndPorts = new List - { - new() - { - Host =_downstreamServiceHost, - Port = port, - }, - }, - DownstreamScheme = _downstreamServiceScheme, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Post" }, - AuthenticationOptions = new FileAuthenticationOptions - { - AuthenticationProviderKey = "Test", - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt)) - .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 201, string.Empty)) - .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .And(x => _steps.GivenThePostHasContent("postContent")) - .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created)) - .BDDfy(); - } - - [Fact] - public void should_return_201_using_identity_server_reference_token() - { - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = _downstreamServicePath, - DownstreamHostAndPorts = new List - { - new() - { - Host =_downstreamServiceHost, - Port = port, - }, - }, - DownstreamScheme = _downstreamServiceScheme, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new List { "Post" }, - AuthenticationOptions = new FileAuthenticationOptions - { - AuthenticationProviderKey = "Test", - }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Reference)) - .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 201, string.Empty)) - .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .And(x => _steps.GivenThePostHasContent("postContent")) - .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created)) - .BDDfy(); - } - - private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - }); - } - - private void GivenThereIsAnIdentityServerOn(string url, string apiName, string api2Name, AccessTokenType tokenType) - { - _identityServerBuilder = new WebHostBuilder() - .UseUrls(url) - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseIISIntegration() - .UseUrls(url) - .ConfigureServices(services => - { - services.AddLogging(); - services.AddIdentityServer() - .AddDeveloperSigningCredential() - .AddInMemoryApiScopes(new List - { - new(apiName, "test"), - new(api2Name, "test"), - }) - .AddInMemoryApiResources(new List - { - new() - { - Name = apiName, - Description = "My API", - Enabled = true, - DisplayName = "test", - Scopes = new List - { - "api", - "api.readOnly", - "openid", - "offline_access", - }, - ApiSecrets = new List - { - new() - { - Value = "secret".Sha256(), - }, - }, - UserClaims = new List - { - "CustomerId", "LocationId", - }, - }, - new() - { - Name = api2Name, - Description = "My second API", - Enabled = true, - DisplayName = "second test", - Scopes = new List - { - "api2", - "api2.readOnly", - }, - ApiSecrets = new List - { - new() - { - Value = "secret".Sha256(), - }, - }, - UserClaims = new List - { - "CustomerId", "LocationId", - }, - }, - }) - .AddInMemoryClients(new List - { - new() - { - ClientId = "client", - AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, - ClientSecrets = new List {new("secret".Sha256())}, - AllowedScopes = new List { apiName, api2Name, "api.readOnly", "openid", "offline_access" }, - AccessTokenType = tokenType, - Enabled = true, - RequireClientSecret = false, - }, - }) - .AddTestUsers(new List - { - new() - { - Username = "test", - Password = "test", - SubjectId = "registered|1231231", - Claims = new List - { - new("CustomerId", "123"), - new("LocationId", "321"), - }, - }, - }); - }) - .Configure(app => - { - app.UseIdentityServer(); - }) - .Build(); - - _identityServerBuilder.Start(); - - Steps.VerifyIdentityServerStarted(url); - } - - public void Dispose() - { - _serviceHandler.Dispose(); - _steps.Dispose(); - _identityServerBuilder?.Dispose(); - } - } -} diff --git a/test/Ocelot.AcceptanceTests/AuthorizationTests.cs b/test/Ocelot.AcceptanceTests/AuthorizationTests.cs index 26dcfb1a4..58f1852a4 100644 --- a/test/Ocelot.AcceptanceTests/AuthorizationTests.cs +++ b/test/Ocelot.AcceptanceTests/AuthorizationTests.cs @@ -5,15 +5,15 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Ocelot.AcceptanceTests.Authentication; using Ocelot.Configuration.File; using System.Security.Claims; namespace Ocelot.AcceptanceTests { - public class AuthorizationTests : IDisposable + public class AuthorizationTests : AuthenticationSteps, IDisposable { private IWebHost _identityServerBuilder; - private readonly Steps _steps; private readonly Action _options; private readonly string _identityServerRootUrl; private readonly ServiceHandler _serviceHandler; @@ -21,7 +21,6 @@ public class AuthorizationTests : IDisposable public AuthorizationTests() { _serviceHandler = new ServiceHandler(); - _steps = new Steps(); var identityServerPort = PortFinder.GetRandomPort(); _identityServerRootUrl = $"http://localhost:{identityServerPort}"; _options = o => @@ -84,13 +83,13 @@ public void should_return_response_200_authorizing_route() this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt)) .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(x => GivenIHaveAToken(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } @@ -143,12 +142,12 @@ public void should_return_response_403_authorizing_route() this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt)) .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden)) + .And(x => GivenIHaveAToken(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden)) .BDDfy(); } @@ -186,12 +185,12 @@ public void should_return_response_200_using_identity_server_with_allowed_scope( this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt)) .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) .BDDfy(); } @@ -229,12 +228,12 @@ public void should_return_response_403_using_identity_server_with_scope_not_allo this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt)) .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden)) + .And(x => GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden)) .BDDfy(); } @@ -290,13 +289,13 @@ public void should_fix_issue_240() this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt, users)) .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura")) - .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning(_options, "Test")) - .And(x => _steps.GivenIHaveAddedATokenToMyRequest()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(x => GivenIHaveAToken(_identityServerRootUrl)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning(_options, "Test")) + .And(x => GivenIHaveAddedATokenToMyRequest()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); } @@ -467,11 +466,14 @@ private void GivenThereIsAnIdentityServerOn(string url, string apiName, AccessTo Steps.VerifyIdentityServerStarted(url); } - public void Dispose() + private async Task GivenIHaveATokenForApiReadOnlyScope(string url) + => await GivenAuthToken(url, "api.readOnly"); + + public override void Dispose() { _serviceHandler?.Dispose(); - _steps.Dispose(); _identityServerBuilder?.Dispose(); + base.Dispose(); } } } diff --git a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs index 2df77bc29..fda947c81 100644 --- a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs +++ b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs @@ -1,172 +1,172 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; -using Ocelot.Configuration.File; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using Ocelot.Configuration.File; namespace Ocelot.AcceptanceTests { public class PollyQoSTests : IDisposable { private readonly Steps _steps; - private readonly ServiceHandler _serviceHandler; - - public PollyQoSTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - } - - private static FileConfiguration FileConfigurationFactory(int port, QoSOptions options, string httpMethod = nameof(HttpMethods.Get)) - => new() - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = Uri.UriSchemeHttp, - DownstreamHostAndPorts = new() - { - new("localhost", port), - }, - UpstreamPathTemplate = "/", - UpstreamHttpMethod = new() {httpMethod}, - QoSOptions = new FileQoSOptions(options), - }, - }, - }; - - [Fact] - public void Should_not_timeout() - { - var port = PortFinder.GetRandomPort(); - var configuration = FileConfigurationFactory(port, new QoSOptions(10, 0, 1000, null), HttpMethods.Post); - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, string.Empty, 10)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithPolly()) - .And(x => _steps.GivenThePostHasContent("postContent")) - .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .BDDfy(); - } - - [Fact] - public void Should_timeout() - { - var port = PortFinder.GetRandomPort(); - var configuration = FileConfigurationFactory(port, new QoSOptions(0, 0, 10, null), HttpMethods.Post); - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 1000)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithPolly()) - .And(x => _steps.GivenThePostHasContent("postContent")) - .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .BDDfy(); - } - - [Fact] - public void Should_open_circuit_breaker_after_two_exceptions() - { - var port = PortFinder.GetRandomPort(); - var configuration = FileConfigurationFactory(port, new QoSOptions(2, 5000, 100000, null)); - - this.Given(x => x.GivenThereIsABrokenServiceRunningOn($"http://localhost:{port}")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithPolly()) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .BDDfy(); - } - - [Fact] - public void Should_open_circuit_breaker_then_close() - { - var port = PortFinder.GetRandomPort(); - var configuration = FileConfigurationFactory(port, new QoSOptions(1, 500, 1000, null)); - - this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port}", "Hello from Laura")) - .Given(x => _steps.GivenThereIsAConfiguration(configuration)) - .Given(x => _steps.GivenOcelotIsRunningWithPolly()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .Given(x => GivenIWaitMilliseconds(3000)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - + private readonly ServiceHandler _serviceHandler; + + public PollyQoSTests() + { + _serviceHandler = new ServiceHandler(); + _steps = new Steps(); + } + + private static FileConfiguration FileConfigurationFactory(int port, QoSOptions options, string httpMethod = nameof(HttpMethods.Get)) + => new() + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = new() + { + new("localhost", port), + }, + UpstreamPathTemplate = "/", + UpstreamHttpMethod = new() {httpMethod}, + QoSOptions = new FileQoSOptions(options), + }, + }, + }; + + [Fact] + public void Should_not_timeout() + { + var port = PortFinder.GetRandomPort(); + var configuration = FileConfigurationFactory(port, new QoSOptions(10, 0, 1000, null), HttpMethods.Post); + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, string.Empty, 10)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithPolly()) + .And(x => _steps.GivenThePostHasContent("postContent")) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .BDDfy(); + } + + [Fact] + public void Should_timeout() + { + var port = PortFinder.GetRandomPort(); + var configuration = FileConfigurationFactory(port, new QoSOptions(0, 0, 10, null), HttpMethods.Post); + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 1000)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithPolly()) + .And(x => _steps.GivenThePostHasContent("postContent")) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .BDDfy(); + } + + [Fact] + public void Should_open_circuit_breaker_after_two_exceptions() + { + var port = PortFinder.GetRandomPort(); + var configuration = FileConfigurationFactory(port, new QoSOptions(2, 5000, 100000, null)); + + this.Given(x => x.GivenThereIsABrokenServiceRunningOn($"http://localhost:{port}")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithPolly()) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .BDDfy(); + } + + [Fact] + public void Should_open_circuit_breaker_then_close() + { + var port = PortFinder.GetRandomPort(); + var configuration = FileConfigurationFactory(port, new QoSOptions(1, 500, 1000, null)); + + this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port}", "Hello from Laura")) + .Given(x => _steps.GivenThereIsAConfiguration(configuration)) + .Given(x => _steps.GivenOcelotIsRunningWithPolly()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .Given(x => GivenIWaitMilliseconds(3000)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void Open_circuit_should_not_effect_different_route() + { + var port1 = PortFinder.GetRandomPort(); + var port2 = PortFinder.GetRandomPort(); + var qos1 = new QoSOptions(1, 1000, 1000, null); + + var configuration = FileConfigurationFactory(port1, qos1); + var route2 = configuration.Routes[0].Clone() as FileRoute; + route2.DownstreamHostAndPorts[0].Port = port2; + route2.UpstreamPathTemplate = "/working"; + route2.QoSOptions = new(); + configuration.Routes.Add(route2); + + this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port1}", "Hello from Laura")) + .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port2}", 200, "Hello from Tom", 0)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithPolly()) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/working")) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Tom")) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .And(x => GivenIWaitMilliseconds(3000)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + [Fact] - public void Open_circuit_should_not_effect_different_route() - { - var port1 = PortFinder.GetRandomPort(); - var port2 = PortFinder.GetRandomPort(); - var qos1 = new QoSOptions(1, 1000, 1000, null); - - var configuration = FileConfigurationFactory(port1, qos1); - var route2 = configuration.Routes[0].Clone() as FileRoute; - route2.DownstreamHostAndPorts[0].Port = port2; - route2.UpstreamPathTemplate = "/working"; - route2.QoSOptions = new(); - configuration.Routes.Add(route2); - - this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port1}", "Hello from Laura")) - .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port2}", 200, "Hello from Tom", 0)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithPolly()) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/working")) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Tom")) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .And(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .And(x => GivenIWaitMilliseconds(3000)) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact(DisplayName = "1833: " + nameof(Should_timeout_per_default_after_90_seconds))] - public void Should_timeout_per_default_after_90_seconds() - { - var port = PortFinder.GetRandomPort(); - var configuration = FileConfigurationFactory(port, new QoSOptions(new FileQoSOptions()), HttpMethods.Get); - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, string.Empty, 95000)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithPolly()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) - .BDDfy(); + [Trait("Bug", "1833")] + public void Should_timeout_per_default_after_90_seconds() + { + var port = PortFinder.GetRandomPort(); + var configuration = FileConfigurationFactory(port, new QoSOptions(new FileQoSOptions()), HttpMethods.Get); + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 95000)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithPolly()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable)) + .BDDfy(); } - private static void GivenIWaitMilliseconds(int ms) => Thread.Sleep(ms); - - private void GivenThereIsABrokenServiceRunningOn(string url) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("this is an exception"); - }); - } + private static void GivenIWaitMilliseconds(int ms) => Thread.Sleep(ms); + + private void GivenThereIsABrokenServiceRunningOn(string url) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("this is an exception"); + }); + } private void GivenThereIsAPossiblyBrokenServiceRunningOn(string url, string responseBody) { @@ -182,22 +182,22 @@ private void GivenThereIsAPossiblyBrokenServiceRunningOn(string url, string resp context.Response.StatusCode = 200; await context.Response.WriteAsync(responseBody); }); - } - - private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody, int timeout) - { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => - { - Thread.Sleep(timeout); - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - }); - } + } + + private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody, int timeout) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + Thread.Sleep(timeout); + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(responseBody); + }); + } public void Dispose() { _serviceHandler?.Dispose(); - _steps.Dispose(); + _steps.Dispose(); GC.SuppressFinalize(this); } } diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs index bf6274c84..e6265b93c 100644 --- a/test/Ocelot.AcceptanceTests/Steps.cs +++ b/test/Ocelot.AcceptanceTests/Steps.cs @@ -39,15 +39,15 @@ namespace Ocelot.AcceptanceTests; public class Steps : IDisposable { - private TestServer _ocelotServer; - private HttpClient _ocelotClient; + protected TestServer _ocelotServer; + protected HttpClient _ocelotClient; private HttpResponseMessage _response; private HttpContent _postContent; private BearerToken _token; public string RequestIdKey = "OcRequestId"; private readonly Random _random; - private readonly string _ocelotConfigFileName; - private IWebHostBuilder _webHostBuilder; + protected readonly string _ocelotConfigFileName; + protected IWebHostBuilder _webHostBuilder; private WebHostBuilder _ocelotBuilder; private IWebHost _ocelotHost; private IOcelotConfigurationChangeTokenSource _changeToken; @@ -666,6 +666,29 @@ public void ThenTheReasonPhraseIs(string expected) _response.ReasonPhrase.ShouldBe(expected); } + public void GivenOcelotIsRunningWithServices(Action configureServices) + { + _webHostBuilder = new WebHostBuilder() + .ConfigureAppConfiguration(WithBasicConfiguration) + .ConfigureServices(configureServices ?? WithAddOcelot) + .Configure(WithUseOcelot); + _ocelotServer = new TestServer(_webHostBuilder); + _ocelotClient = _ocelotServer.CreateClient(); + } + + public void WithBasicConfiguration(WebHostBuilderContext hosting, IConfigurationBuilder config) + { + var env = hosting.HostingEnvironment; + config.SetBasePath(env.ContentRootPath); + config.AddJsonFile("appsettings.json", true, false) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true, false); + config.AddJsonFile(_ocelotConfigFileName, true, false); + config.AddEnvironmentVariables(); + } + + public static void WithAddOcelot(IServiceCollection services) => services.AddOcelot(); + public static void WithUseOcelot(IApplicationBuilder app) => app.UseOcelot().Wait(); + /// /// This is annoying cos it should be in the constructor but we need to set up the file before calling startup so its a step. /// @@ -699,67 +722,34 @@ public void GivenIHaveAddedATokenToMyRequest() _ocelotClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken); } - public void GivenIHaveAToken(string url) + public static List> GivenDefaultAuthTokenForm() => new() { - var tokenUrl = $"{url}/connect/token"; - var formData = new List> - { - new("client_id", "client"), - new("client_secret", "secret"), - new("scope", "api"), - new("username", "test"), - new("password", "test"), - new("grant_type", "password"), - }; - var content = new FormUrlEncodedContent(formData); + new ("client_id", "client"), + new ("client_secret", "secret"), + new ("scope", "api"), + new ("username", "test"), + new ("password", "test"), + new ("grant_type", "password"), + }; - using var httpClient = new HttpClient(); - var response = httpClient.PostAsync(tokenUrl, content).Result; - var responseContent = response.Content.ReadAsStringAsync().Result; - response.EnsureSuccessStatusCode(); - _token = JsonConvert.DeserializeObject(responseContent); - } - - public void GivenIHaveATokenForApiReadOnlyScope(string url) + internal Task GivenIHaveAToken(string url) { - var tokenUrl = $"{url}/connect/token"; - var formData = new List> - { - new("client_id", "client"), - new("client_secret", "secret"), - new("scope", "api.readOnly"), - new("username", "test"), - new("password", "test"), - new("grant_type", "password"), - }; - var content = new FormUrlEncodedContent(formData); - - using var httpClient = new HttpClient(); - var response = httpClient.PostAsync(tokenUrl, content).Result; - var responseContent = response.Content.ReadAsStringAsync().Result; - response.EnsureSuccessStatusCode(); - _token = JsonConvert.DeserializeObject(responseContent); + var form = GivenDefaultAuthTokenForm(); + return GivenIHaveATokenWithForm(url, form); } - public void GivenIHaveATokenForApi2(string url) + internal async Task GivenIHaveATokenWithForm(string url, IEnumerable> form) { var tokenUrl = $"{url}/connect/token"; - var formData = new List> - { - new("client_id", "client"), - new("client_secret", "secret"), - new("scope", "api2"), - new("username", "test"), - new("password", "test"), - new("grant_type", "password"), - }; + var formData = form ?? Enumerable.Empty>(); var content = new FormUrlEncodedContent(formData); using var httpClient = new HttpClient(); - var response = httpClient.PostAsync(tokenUrl, content).Result; - var responseContent = response.Content.ReadAsStringAsync().Result; + var response = await httpClient.PostAsync(tokenUrl, content); + var responseContent = await response.Content.ReadAsStringAsync(); response.EnsureSuccessStatusCode(); _token = JsonConvert.DeserializeObject(responseContent); + return _token; } public static void VerifyIdentityServerStarted(string url) @@ -1201,7 +1191,7 @@ public void Verify(Times howMany) /// /// Public implementation of Dispose pattern callable by consumers. /// - public void Dispose() + public virtual void Dispose() { Dispose(true); GC.SuppressFinalize(this); diff --git a/test/Ocelot.UnitTests/Authentication/AuthenticationMiddlewareTests.cs b/test/Ocelot.UnitTests/Authentication/AuthenticationMiddlewareTests.cs index 8de564526..2201722eb 100644 --- a/test/Ocelot.UnitTests/Authentication/AuthenticationMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/Authentication/AuthenticationMiddlewareTests.cs @@ -1,86 +1,237 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Authentication.Middleware; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Ocelot.Configuration; using Ocelot.Configuration.Builder; -using Ocelot.Infrastructure.RequestData; using Ocelot.Logging; using Ocelot.Middleware; +using System.Security.Claims; +using System.Security.Principal; using System.Text; - -[assembly: CollectionBehavior(DisableTestParallelization = true)] +using AuthenticationMiddleware = Ocelot.Authentication.Middleware.AuthenticationMiddleware; +using AuthenticationOptions = Ocelot.Configuration.AuthenticationOptions; namespace Ocelot.UnitTests.Authentication { public class AuthenticationMiddlewareTests { - private AuthenticationMiddleware _middleware; + private readonly Mock _authentication; private readonly Mock _factory; private readonly Mock _logger; - private RequestDelegate _next; + private readonly Mock _serviceProvider; private readonly HttpContext _httpContext; - private Mock _repo; + + private AuthenticationMiddleware _middleware; + private RequestDelegate _next; public AuthenticationMiddlewareTests() { - _repo = new Mock(); - _httpContext = new DefaultHttpContext(); + _authentication = new Mock(); + _serviceProvider = new Mock(); + _serviceProvider.Setup(sp => sp.GetService(typeof(IAuthenticationService))).Returns(_authentication.Object); + _httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider.Object, + }; _factory = new Mock(); _logger = new Mock(); _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); + _logger.Setup(x => x.LogWarning(It.IsAny>())) + .Callback>(f => _logWarningMessages.Add(f.Invoke())); + } + + [Fact] + public void MiddlewareName_Cstor_ReturnsTypeName() + { + // Arrange + var isNextCalled = false; + _next = (context) => + { + isNextCalled = true; + return Task.CompletedTask; + }; + _middleware = new AuthenticationMiddleware(_next, _factory.Object); + var expected = _middleware.GetType().Name; + + // Act + var actual = _middleware.MiddlewareName; + + // Assert + Assert.False(isNextCalled); + Assert.NotNull(actual); + Assert.Equal(expected, actual); } [Fact] - public void should_call_next_middleware_if_route_is_not_authenticated() + public void Should_call_next_middleware_if_route_is_not_authenticated() { - this.Given(x => GivenTheDownStreamRouteIs( - new DownstreamRouteBuilder().WithUpstreamHttpMethod(new List { "Get" }).Build())) - .And(x => GivenTheTestServerPipelineIsConfigured()) + var methods = new List { "Get" }; + this.Given(x => GivenTheDownStreamRouteIs(new DownstreamRouteBuilder() + .WithUpstreamHttpMethod(methods) + .Build() + )) .When(x => WhenICallTheMiddleware()) .Then(x => ThenTheUserIsAuthenticated()) .BDDfy(); } [Fact] - public void should_call_next_middleware_if_route_is_using_options_method() - { - this.Given(x => GivenTheDownStreamRouteIs( - new DownstreamRouteBuilder() - .WithUpstreamHttpMethod(new List { "Options" }) - .WithIsAuthenticated(true) - .Build())) - .And(x => GivenTheRequestIsUsingOptionsMethod()) + public void Should_call_next_middleware_if_route_is_using_options_method() + { + const string OPTIONS = "OPTIONS"; + var methods = new List { OPTIONS }; + this.Given(x => GivenTheDownStreamRouteIs(new DownstreamRouteBuilder() + .WithUpstreamHttpMethod(methods) + .WithIsAuthenticated(true) + .Build() + )) + .And(x => GivenTheRequestIsUsingMethod(OPTIONS)) .When(x => WhenICallTheMiddleware()) .Then(x => ThenTheUserIsAuthenticated()) .BDDfy(); } - private void WhenICallTheMiddleware() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Should_call_next_middleware_if_route_is_using_several_options_authentication_providers(bool isMultipleKeys) { - _next = (context) => - { - var byteArray = Encoding.ASCII.GetBytes("The user is authenticated"); - var stream = new MemoryStream(byteArray); - _httpContext.Response.Body = stream; - return Task.CompletedTask; - }; - _middleware = new AuthenticationMiddleware(_next, _factory.Object); - _middleware.Invoke(_httpContext).GetAwaiter().GetResult(); + var multipleKeys = new string[] { string.Empty, "Fail", "Test" }; + var options = new AuthenticationOptions(null, + !isMultipleKeys ? "Test" : null, + isMultipleKeys ? multipleKeys : null + ); + var methods = new List { "Get" }; + this.Given(x => GivenTheDownStreamRouteIs(new DownstreamRouteBuilder() + .WithAuthenticationOptions(options) + .WithIsAuthenticated(true) + .WithUpstreamHttpMethod(methods) + .Build() + )) + .And(x => GivenTheRequestIsUsingMethod(methods.First())) + .And(x => GivenTheAuthenticationIsFail()) + .And(x => GivenTheAuthenticationIsSuccess()) + .And(x => GivenTheAuthenticationThrowsException()) + .When(x => WhenICallTheMiddleware()) + .Then(x => ThenTheUserIsAuthenticated()) + .BDDfy(); } - private void GivenTheTestServerPipelineIsConfigured() + [Fact] + public void Should_provide_backward_compatibility_if_route_has_several_options_authentication_providers() { - _next = (context) => + var options = new AuthenticationOptions(null, + "Test", + [string.Empty, "Fail", "Test"] + ); + var methods = new List { "Get" }; + this.Given(x => GivenTheDownStreamRouteIs(new DownstreamRouteBuilder() + .WithAuthenticationOptions(options) + .WithIsAuthenticated(true) + .WithUpstreamHttpMethod(methods) + .Build() + )) + .And(x => GivenTheRequestIsUsingMethod(methods.First())) + .And(x => GivenTheAuthenticationIsFail()) + .And(x => GivenTheAuthenticationIsSuccess()) + .And(x => GivenTheAuthenticationThrowsException()) + .When(x => WhenICallTheMiddleware()) + .Then(x => ThenTheUserIsAuthenticated()) + .BDDfy(); + } + + [Fact] + public void Should_not_call_next_middleware_and_return_no_result_if_all_multiple_keys_were_failed() + { + var options = new AuthenticationOptions(null, null, + [string.Empty, "Fail", "Fail", "UnknownScheme"] + ); + var methods = new List { "Get" }; + + this.Given(x => GivenTheDownStreamRouteIs(new DownstreamRouteBuilder() + .WithAuthenticationOptions(options) + .WithIsAuthenticated(true) + .WithUpstreamHttpMethod(methods) + .Build() + )) + .And(x => GivenTheRequestIsUsingMethod(methods.First())) + .And(x => GivenTheAuthenticationIsFail()) + .And(x => GivenTheAuthenticationIsSuccess()) + .When(x => WhenICallTheMiddleware()) + .Then(x => ThenTheUserIsNotAuthenticated()) + .BDDfy(); + _httpContext.User.Identity.IsAuthenticated.ShouldBeFalse(); + _logWarningMessages.Count.ShouldBe(1); + _logWarningMessages.First().ShouldStartWith("Client has NOT been authenticated for path"); + _httpContext.Items.Errors().First().ShouldBeOfType(typeof(UnauthenticatedError)); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public void Should_not_call_next_middleware_and_return_no_result_if_providers_keys_are_empty(int keysCount) + { + var emptyKeys = new string[keysCount]; + for (int i = 0; i < emptyKeys.Length; i++) { - var byteArray = Encoding.ASCII.GetBytes("The user is authenticated"); - var stream = new MemoryStream(byteArray); - _httpContext.Response.Body = stream; - return Task.CompletedTask; - }; + emptyKeys[i] = i % 2 == 0 ? null : string.Empty; + } + + var optionsWithEmptyKeys = new AuthenticationOptions(null, string.Empty, emptyKeys); + var methods = new List { "Get" }; + var route = new DownstreamRouteBuilder() + .WithAuthenticationOptions(optionsWithEmptyKeys) + .WithIsAuthenticated(true) + .WithUpstreamHttpMethod(methods) + .WithDownstreamPathTemplate("/" + nameof(Should_not_call_next_middleware_and_return_no_result_if_providers_keys_are_empty)) + .Build(); + this.Given(x => GivenTheDownStreamRouteIs(route)) + .And(x => GivenTheRequestIsUsingMethod(methods.First())) + .When(x => WhenICallTheMiddleware()) + .Then(x => ThenTheUserIsNotAuthenticated()) + .BDDfy(); + _httpContext.User.Identity.IsAuthenticated.ShouldBeFalse(); + _logWarningMessages.Count.ShouldBe(2); + _logWarningMessages[0].ShouldStartWith($"Impossible to authenticate client for path '/{nameof(Should_not_call_next_middleware_and_return_no_result_if_providers_keys_are_empty)}':"); + _logWarningMessages[1].ShouldStartWith("Client has NOT been authenticated for path"); + _httpContext.Items.Errors().Count(e => e.GetType() == typeof(UnauthenticatedError)).ShouldBe(1); + } + + private List _logWarningMessages = new(); + + private void GivenTheAuthenticationIsFail() + { + _authentication + .Setup(a => a.AuthenticateAsync(It.IsAny(), It.Is(s => s.Equals("Fail")))) + .Returns(Task.FromResult(AuthenticateResult.Fail("The user is not authenticated."))); } - private void GivenTheRequestIsUsingOptionsMethod() + private void GivenTheAuthenticationIsSuccess() { - _httpContext.Request.Method = "OPTIONS"; + var principal = new Mock(); + var identity = new Mock(); + + identity.Setup(i => i.IsAuthenticated).Returns(true); + principal.Setup(p => p.Identity).Returns(identity.Object); + _authentication + .Setup(a => a.AuthenticateAsync(It.IsAny(), It.Is(s => s.Equals("Test")))) + .Returns(Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal.Object, "Test")))); + } + + private void GivenTheAuthenticationThrowsException() + { + _authentication + .Setup(a => a.AuthenticateAsync(It.IsAny(), It.Is(s => string.Empty.Equals(s)))) + .Throws(new InvalidOperationException("Authentication provider key is empty.")); + } + + private void GivenTheDownStreamRouteIs(DownstreamRoute downstreamRoute) + { + _httpContext.Items.UpsertDownstreamRoute(downstreamRoute); + } + + private void GivenTheRequestIsUsingMethod(string method) + { + _httpContext.Request.Method = method; } private void ThenTheUserIsAuthenticated() @@ -89,9 +240,27 @@ private void ThenTheUserIsAuthenticated() content.ShouldBe("The user is authenticated"); } - private void GivenTheDownStreamRouteIs(DownstreamRoute downstreamRoute) + private void ThenTheUserIsNotAuthenticated() { - _httpContext.Items.UpsertDownstreamRoute(downstreamRoute); + var content = _httpContext.Response.Body.AsString(); + var errors = _httpContext.Items.Errors(); + + content.ShouldBe(string.Empty); + errors.ShouldNotBeEmpty(); + } + + private async void WhenICallTheMiddleware() + { + _next = (context) => + { + byte[] byteArray = Encoding.ASCII.GetBytes("The user is authenticated"); + var stream = new MemoryStream(byteArray); + + _httpContext.Response.Body = stream; + return Task.CompletedTask; + }; + _middleware = new AuthenticationMiddleware(_next, _factory.Object); + await _middleware.Invoke(_httpContext); } } @@ -99,11 +268,9 @@ public static class StreamExtensions { public static string AsString(this Stream stream) { - using (var reader = new StreamReader(stream)) - { - var text = reader.ReadToEnd(); - return text; - } + using var reader = new StreamReader(stream); + var text = reader.ReadToEnd(); + return text; } } } diff --git a/test/Ocelot.UnitTests/Configuration/AuthenticationOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/AuthenticationOptionsCreatorTests.cs index d2d85d208..b1830ba62 100644 --- a/test/Ocelot.UnitTests/Configuration/AuthenticationOptionsCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/AuthenticationOptionsCreatorTests.cs @@ -1,58 +1,73 @@ -using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Configuration.Creator; using Ocelot.Configuration.File; -namespace Ocelot.UnitTests.Configuration +namespace Ocelot.UnitTests.Configuration; + +public class AuthenticationOptionsCreatorTests { - public class AuthenticationOptionsCreatorTests + private readonly AuthenticationOptionsCreator _authOptionsCreator; + + public AuthenticationOptionsCreatorTests() { - private readonly AuthenticationOptionsCreator _authOptionsCreator; - private FileRoute _fileRoute; - private AuthenticationOptions _result; + _authOptionsCreator = new AuthenticationOptionsCreator(); + } - public AuthenticationOptionsCreatorTests() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_OptionsObjIsNull_CreatedSuccessfullyWithEmptyCollections(bool createRoute) + { + // Arrange + FileRoute route = createRoute ? new() : null; + FileAuthenticationOptions options = null; + if (createRoute && route != null) { - _authOptionsCreator = new AuthenticationOptionsCreator(); + route.AuthenticationOptions = options; } - [Fact] - public void should_return_auth_options() - { - var fileRoute = new FileRoute - { - AuthenticationOptions = new FileAuthenticationOptions - { - AuthenticationProviderKey = "Test", - AllowedScopes = new List { "cheese" }, - }, - }; - - var expected = new AuthenticationOptionsBuilder() - .WithAllowedScopes(fileRoute.AuthenticationOptions?.AllowedScopes) - .WithAuthenticationProviderKey("Test") - .Build(); - - this.Given(x => x.GivenTheFollowing(fileRoute)) - .When(x => x.WhenICreateTheAuthenticationOptions()) - .Then(x => x.ThenTheFollowingConfigIsReturned(expected)) - .BDDfy(); - } + // Act + var actual = _authOptionsCreator.Create(route); - private void GivenTheFollowing(FileRoute fileRoute) - { - _fileRoute = fileRoute; - } + // Assert + Assert.NotNull(actual); + Assert.NotNull(actual.AllowedScopes); + Assert.Empty(actual.AllowedScopes); + Assert.NotNull(actual.AuthenticationProviderKey); + Assert.NotNull(actual.AuthenticationProviderKeys); + Assert.Empty(actual.AuthenticationProviderKeys); + } - private void WhenICreateTheAuthenticationOptions() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_OptionsObjIsNotNull_CreatedSuccessfully(bool isAuthenticationProviderKeys) + { + // Arrange + string authenticationProviderKey = !isAuthenticationProviderKeys ? "Test" : null; + string[] authenticationProviderKeys = isAuthenticationProviderKeys ? + ["Test #1", "Test #2"] : null; + var fileRoute = new FileRoute() { - _result = _authOptionsCreator.Create(_fileRoute); - } + AuthenticationOptions = new FileAuthenticationOptions + { + AllowedScopes = new() { "cheese" }, + AuthenticationProviderKey = authenticationProviderKey, + AuthenticationProviderKeys = authenticationProviderKeys, + }, + }; + var expected = new AuthenticationOptionsBuilder() + .WithAllowedScopes(fileRoute.AuthenticationOptions?.AllowedScopes) + .WithAuthenticationProviderKey(authenticationProviderKey) + .WithAuthenticationProviderKeys(authenticationProviderKeys) + .Build(); - private void ThenTheFollowingConfigIsReturned(AuthenticationOptions expected) - { - _result.AllowedScopes.ShouldBe(expected.AllowedScopes); - _result.AuthenticationProviderKey.ShouldBe(expected.AuthenticationProviderKey); - } + // Act + var actual = _authOptionsCreator.Create(fileRoute); + + // Assert + actual.AllowedScopes.ShouldBe(expected.AllowedScopes); + actual.AuthenticationProviderKey.ShouldBe(expected.AuthenticationProviderKey); + actual.AuthenticationProviderKeys.ShouldBe(expected.AuthenticationProviderKeys); } } diff --git a/test/Ocelot.UnitTests/Configuration/RouteOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/RouteOptionsCreatorTests.cs index 2621844c1..bfb2927e5 100644 --- a/test/Ocelot.UnitTests/Configuration/RouteOptionsCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/RouteOptionsCreatorTests.cs @@ -1,76 +1,159 @@ -using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Configuration.Creator; using Ocelot.Configuration.File; -namespace Ocelot.UnitTests.Configuration +namespace Ocelot.UnitTests.Configuration; + +public class RouteOptionsCreatorTests { - public class RouteOptionsCreatorTests - { - private readonly RouteOptionsCreator _creator; - private FileRoute _route; - private RouteOptions _result; + private readonly RouteOptionsCreator _creator; - public RouteOptionsCreatorTests() - { - _creator = new RouteOptionsCreator(); - } + public RouteOptionsCreatorTests() + { + _creator = new RouteOptionsCreator(); + } + + [Fact] + public void Create_ArgumentIsNull_OptionsObjIsCreated() + { + // Arrange, Act + var actual = _creator.Create(null); + + // Assert + Assert.NotNull(actual); + } - [Fact] - public void should_create_re_route_options() + [Fact] + public void Create_AuthenticationOptionsObjIsNull_IsAuthenticatedIsFalse() + { + // Arrange + var route = new FileRoute { AuthenticationOptions = null }; + + // Act + var actual = _creator.Create(route); + + // Assert + Assert.NotNull(actual); + Assert.False(actual.IsAuthenticated); + } + + [Fact] + public void Create_AuthenticationOptionsWithNoProviderKeys_IsAuthenticatedIsFalse() + { + // Arrange + var route = new FileRoute + { + AuthenticationOptions = new(), + }; + + // Act + var actual = _creator.Create(route); + + // Assert + Assert.NotNull(actual); + Assert.False(actual.IsAuthenticated); + } + + [Fact] + public void Create_AuthenticationOptionsWithAuthenticationProviderKeysObjIsNull_IsAuthenticatedIsFalse() + { + // Arrange + var route = new FileRoute + { + AuthenticationOptions = new() + { + AuthenticationProviderKeys = null, + }, + }; + + // Act + var actual = _creator.Create(route); + + // Assert + Assert.NotNull(actual); + Assert.False(actual.IsAuthenticated); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_RouteClaimsRequirementObjIsEmpty_IsAuthorizedIsFalse(bool isEmpty) + { + // Arrange + var route = new FileRoute + { + RouteClaimsRequirement = isEmpty ? new(0) : null, + }; + + // Act + var actual = _creator.Create(route); + + // Assert + Assert.NotNull(actual); + Assert.False(actual.IsAuthorized); + } + + [Fact] + public void Create_RateLimitOptionsObjIsNull_EnableRateLimitingIsFalse() + { + // Arrange + var route = new FileRoute + { + RateLimitOptions = null, + }; + + // Act + var actual = _creator.Create(route); + + // Assert + Assert.NotNull(actual); + Assert.False(actual.EnableRateLimiting); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_RouteOptions_HappyPath(bool isAuthenticationProviderKeys) + { + // Arrange + var route = new FileRoute { - var route = new FileRoute + RateLimitOptions = new FileRateLimitRule { - RateLimitOptions = new FileRateLimitRule - { - EnableRateLimiting = true, - }, - AuthenticationOptions = new FileAuthenticationOptions - { - AuthenticationProviderKey = "Test", - }, - RouteClaimsRequirement = new Dictionary - { - {string.Empty,string.Empty}, - }, - FileCacheOptions = new FileCacheOptions - { - TtlSeconds = 1, - }, - ServiceName = "west", - }; - - var expected = new RouteOptionsBuilder() - .WithIsAuthenticated(true) - .WithIsAuthorized(true) - .WithIsCached(true) - .WithRateLimiting(true) - .WithUseServiceDiscovery(true) - .Build(); - - this.Given(x => x.GivenTheFollowing(route)) - .When(x => x.WhenICreate()) - .Then(x => x.ThenTheFollowingIsReturned(expected)) - .BDDfy(); - } - - private void GivenTheFollowing(FileRoute route) - { - _route = route; - } - - private void WhenICreate() - { - _result = _creator.Create(_route); - } + EnableRateLimiting = true, + }, + AuthenticationOptions = new FileAuthenticationOptions + { + AuthenticationProviderKey = !isAuthenticationProviderKeys ? "Test" : null, + AuthenticationProviderKeys = isAuthenticationProviderKeys ? + [string.Empty, "Test #1"] : null, + }, + RouteClaimsRequirement = new Dictionary + { + {string.Empty, string.Empty}, + }, + FileCacheOptions = new FileCacheOptions + { + TtlSeconds = 1, + }, + ServiceName = "west", + }; + var expected = new RouteOptionsBuilder() + .WithIsAuthenticated(true) + .WithIsAuthorized(true) + .WithIsCached(true) + .WithRateLimiting(true) + .WithUseServiceDiscovery(true) + .Build(); + + // Act + var actual = _creator.Create(route); - private void ThenTheFollowingIsReturned(RouteOptions expected) - { - _result.IsAuthenticated.ShouldBe(expected.IsAuthenticated); - _result.IsAuthorized.ShouldBe(expected.IsAuthorized); - _result.IsCached.ShouldBe(expected.IsCached); - _result.EnableRateLimiting.ShouldBe(expected.EnableRateLimiting); - _result.UseServiceDiscovery.ShouldBe(expected.UseServiceDiscovery); - } + // Assert + actual.IsAuthenticated.ShouldBe(expected.IsAuthenticated); + actual.IsAuthorized.ShouldBe(expected.IsAuthorized); + actual.IsCached.ShouldBe(expected.IsCached); + actual.EnableRateLimiting.ShouldBe(expected.EnableRateLimiting); + actual.UseServiceDiscovery.ShouldBe(expected.UseServiceDiscovery); } } diff --git a/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs b/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs index a6943b4ca..77d2bf791 100644 --- a/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs @@ -23,46 +23,26 @@ public class FileConfigurationFluentValidatorTests private IConfigurationValidator _configurationValidator; private FileConfiguration _fileConfiguration; private Response _result; - private readonly Mock _authProvider; + private IServiceProvider _provider; + private readonly ServiceCollection _services; + private readonly Mock _authProvider; public FileConfigurationFluentValidatorTests() - { + { + _services = new ServiceCollection(); _authProvider = new Mock(); - var provider = new ServiceCollection() - .BuildServiceProvider(); + _provider = _services.BuildServiceProvider(); // Todo - replace with mocks - _configurationValidator = new FileConfigurationFluentValidator(provider, new RouteFluentValidator(_authProvider.Object, new HostAndPortValidator(), new FileQoSOptionsFluentValidator(provider)), new FileGlobalConfigurationFluentValidator(new FileQoSOptionsFluentValidator(provider))); + _configurationValidator = new FileConfigurationFluentValidator(_provider, new RouteFluentValidator(_authProvider.Object, new HostAndPortValidator(), new FileQoSOptionsFluentValidator(_provider)), new FileGlobalConfigurationFluentValidator(new FileQoSOptionsFluentValidator(_provider))); } [Fact] public void configuration_is_valid_if_service_discovery_options_specified_and_has_service_fabric_as_option() - { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = "test", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "https", - Host = "localhost", - Type = "ServiceFabric", - Port = 8500, - }, - }, - }; - + { + var route = GivenServiceDiscoveryRoute(); + var configuration = GivenAConfiguration(route); + configuration.GlobalConfiguration.ServiceDiscoveryProvider = GivenDefaultServiceDiscoveryProvider(); this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) @@ -72,31 +52,10 @@ public void configuration_is_valid_if_service_discovery_options_specified_and_ha [Fact] public void configuration_is_valid_if_service_discovery_options_specified_and_has_service_discovery_handler() { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = "test", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "https", - Host = "localhost", - Type = "FakeServiceDiscoveryProvider", - Port = 8500, - }, - }, - }; - + var route = GivenServiceDiscoveryRoute(); + var configuration = GivenAConfiguration(route); + configuration.GlobalConfiguration.ServiceDiscoveryProvider = GivenDefaultServiceDiscoveryProvider(); + configuration.GlobalConfiguration.ServiceDiscoveryProvider.Type = "FakeServiceDiscoveryProvider"; this.Given(x => x.GivenAConfiguration(configuration)) .And(x => x.GivenAServiceDiscoveryHandler()) .When(x => x.WhenIValidateTheConfiguration()) @@ -107,20 +66,9 @@ public void configuration_is_valid_if_service_discovery_options_specified_and_ha [Fact] public void configuration_is_valid_if_service_discovery_options_specified_dynamically_and_has_service_discovery_handler() { - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "https", - Host = "localhost", - Type = "FakeServiceDiscoveryProvider", - Port = 8500, - }, - }, - }; - + var configuration = new FileConfiguration(); + configuration.GlobalConfiguration.ServiceDiscoveryProvider = GivenDefaultServiceDiscoveryProvider(); + configuration.GlobalConfiguration.ServiceDiscoveryProvider.Type = "FakeServiceDiscoveryProvider"; this.Given(x => x.GivenAConfiguration(configuration)) .And(x => x.GivenAServiceDiscoveryHandler()) .When(x => x.WhenIValidateTheConfiguration()) @@ -131,31 +79,10 @@ public void configuration_is_valid_if_service_discovery_options_specified_dynami [Fact] public void configuration_is_invalid_if_service_discovery_options_specified_but_no_service_discovery_handler() { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = "test", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "https", - Host = "localhost", - Type = "FakeServiceDiscoveryProvider", - Port = 8500, - }, - }, - }; - + var route = GivenServiceDiscoveryRoute(); + var configuration = GivenAConfiguration(route); + configuration.GlobalConfiguration.ServiceDiscoveryProvider = GivenDefaultServiceDiscoveryProvider(); + configuration.GlobalConfiguration.ServiceDiscoveryProvider.Type = "FakeServiceDiscoveryProvider"; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) @@ -167,20 +94,9 @@ public void configuration_is_invalid_if_service_discovery_options_specified_but_ [Fact] public void configuration_is_invalid_if_service_discovery_options_specified_dynamically_but_service_discovery_handler() { - var configuration = new FileConfiguration - { - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "https", - Host = "localhost", - Type = "FakeServiceDiscoveryProvider", - Port = 8500, - }, - }, - }; - + var configuration = new FileConfiguration(); + configuration.GlobalConfiguration.ServiceDiscoveryProvider = GivenDefaultServiceDiscoveryProvider(); + configuration.GlobalConfiguration.ServiceDiscoveryProvider.Type = "FakeServiceDiscoveryProvider"; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) @@ -191,32 +107,11 @@ public void configuration_is_invalid_if_service_discovery_options_specified_dyna [Fact] public void configuration_is_invalid_if_service_discovery_options_specified_but_no_service_discovery_handler_with_matching_name() - { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - ServiceName = "test", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "https", - Host = "localhost", - Type = "consul", - Port = 8500, - }, - }, - }; - + { + var route = GivenServiceDiscoveryRoute(); + var configuration = GivenAConfiguration(route); + configuration.GlobalConfiguration.ServiceDiscoveryProvider = GivenDefaultServiceDiscoveryProvider(); + configuration.GlobalConfiguration.ServiceDiscoveryProvider.Type = "consul"; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .And(x => x.GivenAServiceDiscoveryHandler()) @@ -228,36 +123,15 @@ public void configuration_is_invalid_if_service_discovery_options_specified_but_ [Fact] public void configuration_is_valid_if_qos_options_specified_and_has_qos_handler() - { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - QoSOptions = new FileQoSOptions - { - TimeoutValue = 1, - ExceptionsAllowedBeforeBreaking = 1, - }, - }, - }, + { + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + route.QoSOptions = new FileQoSOptions + { + TimeoutValue = 1, + ExceptionsAllowedBeforeBreaking = 1, }; - - this.Given(x => x.GivenAConfiguration(configuration)) + this.Given(x => x.GivenAConfiguration(route)) .And(x => x.GivenAQoSHandler()) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) @@ -267,37 +141,14 @@ public void configuration_is_valid_if_qos_options_specified_and_has_qos_handler( [Fact] public void configuration_is_valid_if_qos_options_specified_globally_and_has_qos_handler() { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - QoSOptions = new FileQoSOptions - { - TimeoutValue = 1, - ExceptionsAllowedBeforeBreaking = 1, - }, - }, + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var configuration = GivenAConfiguration(route); + configuration.GlobalConfiguration.QoSOptions = new FileQoSOptions + { + TimeoutValue = 1, + ExceptionsAllowedBeforeBreaking = 1, }; - this.Given(x => x.GivenAConfiguration(configuration)) .And(x => x.GivenAQoSHandler()) .When(x => x.WhenIValidateTheConfiguration()) @@ -308,35 +159,14 @@ public void configuration_is_valid_if_qos_options_specified_globally_and_has_qos [Fact] public void configuration_is_invalid_if_qos_options_specified_but_no_qos_handler() { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - QoSOptions = new FileQoSOptions - { - TimeoutValue = 1, - ExceptionsAllowedBeforeBreaking = 1, - }, - }, - }, + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + route.QoSOptions = new FileQoSOptions + { + TimeoutValue = 1, + ExceptionsAllowedBeforeBreaking = 1, }; - - this.Given(x => x.GivenAConfiguration(configuration)) + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorIs()) @@ -347,37 +177,14 @@ public void configuration_is_invalid_if_qos_options_specified_but_no_qos_handler [Fact] public void configuration_is_invalid_if_qos_options_specified_globally_but_no_qos_handler() { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - QoSOptions = new FileQoSOptions - { - TimeoutValue = 1, - ExceptionsAllowedBeforeBreaking = 1, - }, - }, + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var configuration = GivenAConfiguration(route); + configuration.GlobalConfiguration.QoSOptions = new FileQoSOptions + { + TimeoutValue = 1, + ExceptionsAllowedBeforeBreaking = 1, }; - this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) @@ -389,58 +196,24 @@ public void configuration_is_invalid_if_qos_options_specified_globally_but_no_qo [Fact] public void configuration_is_valid_if_aggregates_are_valid() { - var configuration = new FileConfiguration + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var route2 = GivenDefaultRoute("/tom", "/"); + route2.Key = "Tom"; + var configuration = GivenAConfiguration(route, route2); + configuration.Aggregates = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51880, - }, - }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - }, - }, - Aggregates = new List - { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Tom", - "Laura", - }, - }, - }, - }; - + new() + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Tom", + "Laura", + ], + }, + }; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) @@ -448,61 +221,27 @@ public void configuration_is_valid_if_aggregates_are_valid() } [Fact] - public void configuration_is_invalid_if_aggregates_are_duplicate_of_re_routes() + public void configuration_is_invalid_if_aggregates_are_duplicate_of_routes() { - var configuration = new FileConfiguration + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var route2 = GivenDefaultRoute("/tom", "/"); + route2.Key = "Tom"; + route2.UpstreamHost = "localhost"; + var configuration = GivenAConfiguration(route, route2); + configuration.Aggregates = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51880, - }, - }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - UpstreamHost = "localhost", - }, - }, - Aggregates = new List - { - new() - { - UpstreamPathTemplate = "/tom", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Tom", - "Laura", - }, - }, - }, - }; - + new() + { + UpstreamPathTemplate = "/tom", + UpstreamHost = "localhost", + RouteKeys = + [ + "Tom", + "Laura", + ], + }, + }; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) @@ -511,61 +250,27 @@ public void configuration_is_invalid_if_aggregates_are_duplicate_of_re_routes() } [Fact] - public void configuration_is_valid_if_aggregates_are_not_duplicate_of_re_routes() + public void configuration_is_valid_if_aggregates_are_not_duplicate_of_routes() { - var configuration = new FileConfiguration + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var route2 = GivenDefaultRoute("/tom", "/"); + route2.Key = "Tom"; + route2.UpstreamHttpMethod = new List { "Post" }; + var configuration = GivenAConfiguration(route, route2); + configuration.Aggregates = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51880, - }, - }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Post" }, - Key = "Tom", - UpstreamHost = "localhost", - }, - }, - Aggregates = new List - { - new() - { - UpstreamPathTemplate = "/tom", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Tom", - "Laura", - }, - }, - }, - }; - + new() + { + UpstreamPathTemplate = "/tom", + UpstreamHost = "localhost", + RouteKeys = + [ + "Tom", + "Laura", + ], + }, + }; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) @@ -574,69 +279,35 @@ public void configuration_is_valid_if_aggregates_are_not_duplicate_of_re_routes( [Fact] public void configuration_is_invalid_if_aggregates_are_duplicate_of_aggregates() - { - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51880, - }, - }, - UpstreamPathTemplate = "/lol", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - }, - }, - Aggregates = new List - { - new() - { - UpstreamPathTemplate = "/tom", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Tom", - "Laura", - }, - }, - new() - { - UpstreamPathTemplate = "/tom", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Tom", - "Laura", - }, - }, - }, - }; - + { + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var route2 = GivenDefaultRoute("/lol", "/"); + route2.Key = "Tom"; + var configuration = GivenAConfiguration(route, route2); + configuration.Aggregates = new List + { + new() + { + UpstreamPathTemplate = "/tom", + UpstreamHost = "localhost", + RouteKeys = + [ + "Tom", + "Laura", + ], + }, + new() + { + UpstreamPathTemplate = "/tom", + UpstreamHost = "localhost", + RouteKeys = + [ + "Tom", + "Laura", + ], + }, + }; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) @@ -645,44 +316,24 @@ public void configuration_is_invalid_if_aggregates_are_duplicate_of_aggregates() } [Fact] - public void configuration_is_invalid_if_re_routes_dont_exist_for_aggregate() + public void configuration_is_invalid_if_routes_dont_exist_for_aggregate() { - var configuration = new FileConfiguration + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var configuration = GivenAConfiguration(route); + configuration.Aggregates = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - }, - Aggregates = new List - { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Tom", - "Laura", - }, - }, - }, + new() + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Tom", + "Laura", + ], + }, }; - this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) @@ -691,61 +342,27 @@ public void configuration_is_invalid_if_re_routes_dont_exist_for_aggregate() } [Fact] - public void configuration_is_invalid_if_aggregate_has_re_routes_with_specific_request_id_keys() + public void configuration_is_invalid_if_aggregate_has_routes_with_specific_request_id_keys() { - var configuration = new FileConfiguration + var route = GivenDefaultRoute("/laura", "/"); + route.Key = "Laura"; + var route2 = GivenDefaultRoute("/tom", "/"); + route2.Key = "Tom"; + route2.RequestIdKey = "should_fail"; + var configuration = GivenAConfiguration(route, route2); + configuration.Aggregates = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51878, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = 51880, - }, - }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - RequestIdKey = "should_fail", - Key = "Tom", - }, - }, - Aggregates = new List - { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Tom", - "Laura", - }, - }, - }, - }; - + new() + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Tom", + "Laura", + ], + }, + }; this.Given(x => x.GivenAConfiguration(configuration)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) @@ -756,17 +373,7 @@ public void configuration_is_invalid_if_aggregate_has_re_routes_with_specific_re [Fact] public void configuration_is_invalid_if_scheme_in_downstream_or_upstream_template() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "http://www.bbc.co.uk/api/products/{productId}", - UpstreamPathTemplate = "http://asdf.com", - }, - }, - })) + this.Given(x => x.GivenAConfiguration(GivenDefaultRoute("http://asdf.com", "http://www.bbc.co.uk/api/products/{productId}"))) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .Then(x => x.ThenTheErrorIs()) @@ -783,24 +390,7 @@ public void configuration_is_invalid_if_scheme_in_downstream_or_upstream_templat [Fact] public void configuration_is_valid_with_one_route() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - }, - }, - })) + this.Given(x => x.GivenAConfiguration(GivenDefaultRoute())) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -809,17 +399,7 @@ public void configuration_is_valid_with_one_route() [Fact] public void configuration_is_invalid_without_slash_prefix_downstream_path_template() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "api/products/", - UpstreamPathTemplate = "/asdf/", - }, - }, - })) + this.Given(x => x.GivenAConfiguration(GivenDefaultRoute("/asdf/", "api/products/"))) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "Downstream Path Template api/products/ doesnt start with forward slash")) @@ -828,18 +408,8 @@ public void configuration_is_invalid_without_slash_prefix_downstream_path_templa [Fact] public void configuration_is_invalid_without_slash_prefix_upstream_path_template() - { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "api/prod/", - }, - }, - })) + { + this.Given(x => x.GivenAConfiguration(GivenDefaultRoute("api/prod/", "/api/products/"))) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "Upstream Path Template api/prod/ doesnt start with forward slash")) @@ -849,25 +419,7 @@ public void configuration_is_invalid_without_slash_prefix_upstream_path_template [Fact] public void configuration_is_invalid_if_upstream_url_contains_forward_slash_then_another_forward_slash() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "//api/prod/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - Port = 80, - }, - }, - }, - }, - })) + this.Given(x => x.GivenAConfiguration(GivenDefaultRoute("//api/prod/", "/api/products/"))) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "Upstream Path Template //api/prod/ contains double forward slash, Ocelot does not support this at the moment. Please raise an issue in GitHib if you need this feature.")) @@ -877,25 +429,7 @@ public void configuration_is_invalid_if_upstream_url_contains_forward_slash_then [Fact] public void configuration_is_invalid_if_downstream_url_contains_forward_slash_then_another_forward_slash() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "//api/products/", - UpstreamPathTemplate = "/api/prod/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - Port = 80, - }, - }, - }, - }, - })) + this.Given(x => x.GivenAConfiguration(GivenDefaultRoute("/api/prod/", "//api/products/"))) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "Downstream Path Template //api/products/ contains double forward slash, Ocelot does not support this at the moment. Please raise an issue in GitHib if you need this feature.")) @@ -904,29 +438,10 @@ public void configuration_is_invalid_if_downstream_url_contains_forward_slash_th [Fact] public void configuration_is_valid_with_valid_authentication_provider() - { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - AuthenticationOptions = new FileAuthenticationOptions() - { - AuthenticationProviderKey = "Test", - }, - }, - }, - })) + { + var route = GivenDefaultRoute(); + route.AuthenticationOptions.AuthenticationProviderKey = "Test"; + this.Given(x => x.GivenAConfiguration(route)) .And(x => x.GivenTheAuthSchemeExists("Test")) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) @@ -936,101 +451,40 @@ public void configuration_is_valid_with_valid_authentication_provider() [Fact] public void configuration_is_invalid_with_invalid_authentication_provider() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - AuthenticationOptions = new FileAuthenticationOptions() - { - AuthenticationProviderKey = "Test", - }, - }, - }, - })) + var route = GivenDefaultRoute(); + route.AuthenticationOptions = new FileAuthenticationOptions() + { + AuthenticationProviderKey = "Test", + AuthenticationProviderKeys = new[] { "Test #1", "Test #2" }, + }; + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) - .And(x => x.ThenTheErrorMessageAtPositionIs(0, "Authentication Options AuthenticationProviderKey:Test,AllowedScopes:[] is unsupported authentication provider")) + .And(x => x.ThenTheErrorMessageAtPositionIs(0, "Authentication Options AuthenticationProviderKey:'Test',AuthenticationProviderKeys:['Test #1','Test #2'],AllowedScopes:[] is unsupported authentication provider")) .BDDfy(); } [Fact] public void configuration_is_not_valid_with_duplicate_routes_all_verbs() - { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bb.co.uk", - }, - }, - }, - new() - { - DownstreamPathTemplate = "/www/test/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bb.co.uk", - }, - }, - }, - }, - })) + { + var route = GivenDefaultRoute(); + var duplicate = GivenDefaultRoute(); + duplicate.DownstreamPathTemplate = "/www/test/"; + this.Given(x => x.GivenAConfiguration(route, duplicate)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) - .And(x => x.ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate")) + .And(x => x.ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate")) .BDDfy(); } [Fact] public void configuration_is_valid_with_duplicate_routes_all_verbs_but_different_hosts() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bb.co.uk", - }, - }, - UpstreamHost = "host1", - }, - new() - { - DownstreamPathTemplate = "/www/test/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bb.co.uk", - }, - }, - UpstreamHost = "host2", - }, - }, - })) + var route = GivenDefaultRoute(); + route.UpstreamHost = "host1"; + var duplicate = GivenDefaultRoute(null, "/www/test/"); + duplicate.UpstreamHost = "host2"; + this.Given(x => x.GivenAConfiguration(route, duplicate)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -1039,38 +493,10 @@ public void configuration_is_valid_with_duplicate_routes_all_verbs_but_different [Fact] public void configuration_is_not_valid_with_duplicate_routes_specific_verbs() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List {"Get"}, - }, - new() - { - DownstreamPathTemplate = "/www/test/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List {"Get"}, - }, - }, - })) + var route = GivenDefaultRoute(); + var duplicate = GivenDefaultRoute(null, "/www/test/"); + duplicate.UpstreamHttpMethod = new List { "Get" }; + this.Given(x => x.GivenAConfiguration(route, duplicate)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate")) @@ -1080,38 +506,10 @@ public void configuration_is_not_valid_with_duplicate_routes_specific_verbs() [Fact] public void configuration_is_valid_with_duplicate_routes_different_verbs() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - }, - new() - { - DownstreamPathTemplate = "/www/test/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Post"}, - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - }, - }, - })) + var route = GivenDefaultRoute(); // "Get" verb is inside + var duplicate = GivenDefaultRoute(null, "/www/test/"); + duplicate.UpstreamHttpMethod = new List { "Post" }; + this.Given(x => x.GivenAConfiguration(route, duplicate)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -1120,40 +518,15 @@ public void configuration_is_valid_with_duplicate_routes_different_verbs() [Fact] public void configuration_is_not_valid_with_duplicate_routes_with_duplicated_upstreamhosts() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List(), - UpstreamHost = "upstreamhost", - }, - new() - { - DownstreamPathTemplate = "/www/test/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List(), - UpstreamHost = "upstreamhost", - }, - }, - })) + var route = GivenDefaultRoute(); + route.UpstreamHttpMethod = new(); + route.UpstreamHost = "upstreamhost"; + + var duplicate = GivenDefaultRoute(null, "/www/test/"); + duplicate.UpstreamHttpMethod = new(); + duplicate.UpstreamHost = "upstreamhost"; + + this.Given(x => x.GivenAConfiguration(route, duplicate)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate")) @@ -1163,40 +536,15 @@ public void configuration_is_not_valid_with_duplicate_routes_with_duplicated_ups [Fact] public void configuration_is_valid_with_duplicate_routes_but_different_upstreamhosts() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List(), - UpstreamHost = "upstreamhost111", - }, - new() - { - DownstreamPathTemplate = "/www/test/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List(), - UpstreamHost = "upstreamhost222", - }, - }, - })) + var route = GivenDefaultRoute(); + route.UpstreamHttpMethod = new(); + route.UpstreamHost = "upstreamhost111"; + + var duplicate = GivenDefaultRoute(null, "/www/test/"); + duplicate.UpstreamHttpMethod = new(); + duplicate.UpstreamHost = "upstreamhost222"; + + this.Given(x => x.GivenAConfiguration(route, duplicate)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -1205,39 +553,14 @@ public void configuration_is_valid_with_duplicate_routes_but_different_upstreamh [Fact] public void configuration_is_valid_with_duplicate_routes_but_one_upstreamhost_is_not_set() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List(), - UpstreamHost = "upstreamhost", - }, - new() - { - DownstreamPathTemplate = "/www/test/", - UpstreamPathTemplate = "/asdf/", - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - UpstreamHttpMethod = new List(), - }, - }, - })) + var route = GivenDefaultRoute(); + route.UpstreamHttpMethod = new(); + route.UpstreamHost = "upstreamhost"; + + var duplicate = GivenDefaultRoute(null, "/www/test/"); + duplicate.UpstreamHttpMethod = new(); + + this.Given(x => x.GivenAConfiguration(route, duplicate)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -1246,30 +569,13 @@ public void configuration_is_valid_with_duplicate_routes_but_one_upstreamhost_is [Fact] public void configuration_is_invalid_with_invalid_rate_limit_configuration() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - RateLimitOptions = new FileRateLimitRule - { - Period = "1x", - EnableRateLimiting = true, - }, - }, - }, - })) + var route = GivenDefaultRoute(); + route.RateLimitOptions = new FileRateLimitRule + { + Period = "1x", + EnableRateLimiting = true, + }; + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "RateLimitOptions.Period does not contain integer then s (second), m (minute), h (hour), d (day) e.g. 1m for 1 minute period")) @@ -1279,30 +585,13 @@ public void configuration_is_invalid_with_invalid_rate_limit_configuration() [Fact] public void configuration_is_valid_with_valid_rate_limit_configuration() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - RateLimitOptions = new FileRateLimitRule - { - Period = "1d", - EnableRateLimiting = true, - }, - }, - }, - })) + var route = GivenDefaultRoute(); + route.RateLimitOptions = new FileRateLimitRule + { + Period = "1d", + EnableRateLimiting = true, + }; + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -1311,86 +600,108 @@ public void configuration_is_valid_with_valid_rate_limit_configuration() [Fact] public void configuration_is_valid_with_using_service_discovery_and_service_name() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - ServiceName = "Test", - }, - }, - GlobalConfiguration = new FileGlobalConfiguration - { - ServiceDiscoveryProvider = new FileServiceDiscoveryProvider - { - Scheme = "https", - Type = "servicefabric", - Host = "localhost", - Port = 1234, - }, - }, - })) + var route = GivenServiceDiscoveryRoute(); + var config = GivenAConfiguration(route); + config.GlobalConfiguration.ServiceDiscoveryProvider = GivenDefaultServiceDiscoveryProvider(); + this.Given(x => x.GivenAConfiguration(config)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); } + + private const string Empty = ""; [Theory] [InlineData(null)] - [InlineData("")] + [InlineData(Empty)] public void configuration_is_invalid_when_not_using_service_discovery_and_host(string downstreamHost) - { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - new() - { - Host = downstreamHost, - }, - }, - }, - }, - })) + { + var route = GivenDefaultRoute(); + route.DownstreamHostAndPorts[0].Host = downstreamHost; + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "When not using service discovery Host must be set on DownstreamHostAndPorts if you are not using Route.Host or Ocelot cannot find your service!")) .BDDfy(); + } + + [Theory] + [InlineData(null, true)] + [InlineData(Empty, true)] + [InlineData("Test", false)] + public void HaveServiceDiscoveryProviderRegistered_RouteServiceName_Validated(string serviceName, bool valid) + { + // Arrange + var route = GivenDefaultRoute(); + route.ServiceName = serviceName; + var config = GivenAConfiguration(route); + config.GlobalConfiguration.ServiceDiscoveryProvider = null; + + // Act + WhenIValidateTheConfiguration(); + + // Assert + _result.Data.IsError.ShouldNotBe(valid); + _result.Data.Errors.Count.ShouldBe(valid ? 0 : 1); } + [Theory] + [InlineData(false, null, false)] + [InlineData(true, null, false)] + [InlineData(true, "type", false)] + [InlineData(true, "servicefabric", true)] + public void HaveServiceDiscoveryProviderRegistered_ServiceDiscoveryProvider_Validated(bool create, string type, bool valid) + { + // Arrange + var route = GivenServiceDiscoveryRoute(); + var config = GivenAConfiguration(route); + var provider = create ? GivenDefaultServiceDiscoveryProvider() : null; + config.GlobalConfiguration.ServiceDiscoveryProvider = provider; + if (create && provider != null) + { + provider.Type = type; + } + + // Act + WhenIValidateTheConfiguration(); + + // Assert + _result.Data.IsError.ShouldNotBe(valid); + _result.Data.Errors.Count.ShouldBeGreaterThanOrEqualTo(valid ? 0 : 1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void HaveServiceDiscoveryProviderRegistered_ServiceDiscoveryFinderDelegates_Validated(bool hasDelegate) + { + // Arrange + var valid = hasDelegate; + var route = GivenServiceDiscoveryRoute(); + var config = GivenAConfiguration(route); + config.GlobalConfiguration.ServiceDiscoveryProvider = null; + if (hasDelegate) + { + GivenAServiceDiscoveryHandler(); + } + + // Act + WhenIValidateTheConfiguration(); + + // Assert + _result.Data.IsError.ShouldNotBe(valid); + _result.Data.Errors.Count.ShouldBe(valid ? 0 : 1); + } + [Fact] public void configuration_is_valid_when_not_using_service_discovery_and_host_is_set() - { - this.Given(x => x.GivenAConfiguration(new FileConfiguration + { + var route = GivenDefaultRoute(); + route.DownstreamHostAndPorts = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - new() - { - Host = "bbc.co.uk", - }, - }, - }, - }, - })) + new("bbc.co.uk", 123), + }; + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -1399,25 +710,12 @@ public void configuration_is_valid_when_not_using_service_discovery_and_host_is_ [Fact] public void configuration_is_valid_when_no_downstream_but_has_host_and_port() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration + var route = GivenDefaultRoute(); + route.DownstreamHostAndPorts = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - new() - { - Host = "test", - }, - }, - }, - }, - })) + new("test", 123), + }; + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); @@ -1426,82 +724,83 @@ public void configuration_is_valid_when_no_downstream_but_has_host_and_port() [Fact] public void configuration_is_not_valid_when_no_host_and_port() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - }, - }, - }, - })) + var route = GivenDefaultRoute(); + route.DownstreamHostAndPorts = new(); + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) - .And(x => x.ThenTheErrorMessageAtPositionIs(0, "When not using service discovery DownstreamHostAndPorts must be set and not empty or Ocelot cannot find your service!")) + .And(x => x.ThenTheErrorMessageAtPositionIs(0, "When not using service discovery DownstreamHostAndPorts must be set and not empty or Ocelot cannot find your service!")) .BDDfy(); } [Fact] public void configuration_is_not_valid_when_host_and_port_is_empty() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration + var route = GivenDefaultRoute(); + route.DownstreamHostAndPorts = new List { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/products/", - UpstreamPathTemplate = "/asdf/", - UpstreamHttpMethod = new List {"Get"}, - DownstreamHostAndPorts = new List - { - new(), - }, - }, - }, - })) + new(), + }; + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) - .And(x => x.ThenTheErrorMessageAtPositionIs(0, "When not using service discovery Host must be set on DownstreamHostAndPorts if you are not using Route.Host or Ocelot cannot find your service!")) + .And(x => x.ThenTheErrorMessageAtPositionIs(0, "When not using service discovery Host must be set on DownstreamHostAndPorts if you are not using Route.Host or Ocelot cannot find your service!")) .BDDfy(); } [Fact] public void configuration_is_invalid_when_placeholder_is_used_twice_in_upstream_path_template() { - this.Given(x => x.GivenAConfiguration(new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/bar/{everything}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() { Host = "a.b.cd" }, - }, - UpstreamPathTemplate = "/foo/bar/{everything}/{everything}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - })) + var route = GivenDefaultRoute("/foo/bar/{everything}/{everything}", "/bar/{everything}"); + this.Given(x => x.GivenAConfiguration(route)) .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "route /foo/bar/{everything}/{everything} has duplicated placeholder")) .BDDfy(); - } + } + + private FileRoute GivenDefaultRoute() => GivenDefaultRoute(null, null); + + private FileRoute GivenDefaultRoute(string upstreamPathTemplate, string downstreamPathTemplate) => new() + { + DownstreamPathTemplate = downstreamPathTemplate ?? "/api/products/", + UpstreamPathTemplate = upstreamPathTemplate ?? "/asdf/", + UpstreamHttpMethod = new List { "Get" }, + DownstreamHostAndPorts = new List + { + new("bbc.co.uk", 12345), + }, + }; + + private FileRoute GivenServiceDiscoveryRoute() => new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + UpstreamPathTemplate = "/laura", + UpstreamHttpMethod = new List { "Get" }, + ServiceName = "test", + }; private void GivenAConfiguration(FileConfiguration fileConfiguration) { _fileConfiguration = fileConfiguration; - } + } + + private FileConfiguration GivenAConfiguration(params FileRoute[] routes) + { + var config = new FileConfiguration(); + config.Routes.AddRange(routes); + _fileConfiguration = config; + return config; + } + + private FileServiceDiscoveryProvider GivenDefaultServiceDiscoveryProvider() => new FileServiceDiscoveryProvider + { + Scheme = "https", + Host = "localhost", + Type = "ServiceFabric", + Port = 8500, + }; private void WhenIValidateTheConfiguration() { @@ -1538,33 +837,26 @@ private void GivenTheAuthSchemeExists(string name) private void GivenAQoSHandler() { - var collection = new ServiceCollection(); DelegatingHandler Del(DownstreamRoute a, IHttpContextAccessor b, IOcelotLoggerFactory c) => new FakeDelegatingHandler(); - collection.AddSingleton((QosDelegatingHandlerDelegate)Del); - var provider = collection.BuildServiceProvider(); - _configurationValidator = new FileConfigurationFluentValidator(provider, new RouteFluentValidator(_authProvider.Object, new HostAndPortValidator(), new FileQoSOptionsFluentValidator(provider)), new FileGlobalConfigurationFluentValidator(new FileQoSOptionsFluentValidator(provider))); + _services.AddSingleton((QosDelegatingHandlerDelegate)Del); + _provider = _services.BuildServiceProvider(); + _configurationValidator = new FileConfigurationFluentValidator(_provider, new RouteFluentValidator(_authProvider.Object, new HostAndPortValidator(), new FileQoSOptionsFluentValidator(_provider)), new FileGlobalConfigurationFluentValidator(new FileQoSOptionsFluentValidator(_provider))); } private void GivenAServiceDiscoveryHandler() { - var collection = new ServiceCollection(); ServiceDiscoveryFinderDelegate del = (a, b, c) => new FakeServiceDiscoveryProvider(); - collection.AddSingleton(del); - var provider = collection.BuildServiceProvider(); - _configurationValidator = new FileConfigurationFluentValidator(provider, new RouteFluentValidator(_authProvider.Object, new HostAndPortValidator(), new FileQoSOptionsFluentValidator(provider)), new FileGlobalConfigurationFluentValidator(new FileQoSOptionsFluentValidator(provider))); + _services.AddSingleton(del); + _provider = _services.BuildServiceProvider(); + _configurationValidator = new FileConfigurationFluentValidator(_provider, new RouteFluentValidator(_authProvider.Object, new HostAndPortValidator(), new FileQoSOptionsFluentValidator(_provider)), new FileGlobalConfigurationFluentValidator(new FileQoSOptionsFluentValidator(_provider))); } private class FakeServiceDiscoveryProvider : IServiceDiscoveryProvider { - public Task> GetAsync() - { - throw new System.NotImplementedException(); - } + public Task> GetAsync() => Task.FromResult>(new()); } - private class TestOptions : AuthenticationSchemeOptions - { - } + private class TestOptions : AuthenticationSchemeOptions { } private class TestHandler : AuthenticationHandler { diff --git a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs index accc5a94d..2e696f41d 100644 --- a/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/Validation/RouteFluentValidatorTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Ocelot.Configuration.File; using Ocelot.Configuration.Validator; +using System.Reflection; namespace Ocelot.UnitTests.Configuration.Validation { @@ -190,6 +191,31 @@ public void should_not_be_valid_if_enable_rate_limiting_true_and_period_has_valu .Then(_ => ThenTheResultIsInvalid()) .And(_ => ThenTheErrorsContains("RateLimitOptions.Period does not contain integer then s (second), m (minute), h (hour), d (day) e.g. 1m for 1 minute period")) .BDDfy(); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("1s", true)] + [InlineData("2m", true)] + [InlineData("3h", true)] + [InlineData("4d", true)] + [InlineData("123", false)] + [InlineData("-123", false)] + [InlineData("bad", false)] + [InlineData(" 3s ", true)] + [InlineData(" -3s ", false)] + public void IsValidPeriod_ReflectionLifeHack_BranchesAreCovered(string period, bool expected) + { + // Arrange + var method = _validator.GetType().GetMethod("IsValidPeriod", BindingFlags.NonPublic | BindingFlags.Static); + var argument = new FileRateLimitRule { Period = period }; + + // Act + bool actual = (bool)method.Invoke(_validator, new object[] { argument }); + + // Assert + Assert.Equal(expected, actual); } [Fact] @@ -208,7 +234,7 @@ public void should_not_be_valid_if_specified_authentication_provider_isnt_regist this.Given(_ => GivenThe(fileRoute)) .When(_ => WhenIValidate()) .Then(_ => ThenTheResultIsInvalid()) - .And(_ => ThenTheErrorsContains($"Authentication Options AuthenticationProviderKey:JwtLads,AllowedScopes:[] is unsupported authentication provider")) + .And(_ => ThenTheErrorsContains($"Authentication Options AuthenticationProviderKey:'JwtLads',AuthenticationProviderKeys:[],AllowedScopes:[] is unsupported authentication provider")) .BDDfy(); } diff --git a/test/Ocelot.UnitTests/Polly/PollyQoSProviderTests.cs b/test/Ocelot.UnitTests/Polly/PollyQoSProviderTests.cs index 95b59757e..6ebfeee93 100644 --- a/test/Ocelot.UnitTests/Polly/PollyQoSProviderTests.cs +++ b/test/Ocelot.UnitTests/Polly/PollyQoSProviderTests.cs @@ -134,7 +134,8 @@ public async Task should_not_throw_broken_circuit_exception_if_status_code_ok() Assert.Equal(HttpStatusCode.OK, (await pollyPolicyWrapper.AsyncPollyPolicy.ExecuteAsync(() => Task.FromResult(response))).StatusCode); } - [Fact] + [Fact(Skip = "TODO", DisplayName = "TODO " + nameof(should_throw_and_before_delay_should_not_allow_requests))] + [Trait("TODO", "Fix after the release")] public async Task should_throw_and_before_delay_should_not_allow_requests() { var pollyPolicyWrapper = PolicyWrapperFactory("/", PollyQoSProviderFactory()); From a1607f5aa59ba9bf9354f40d16f0fac929743ca0 Mon Sep 17 00:00:00 2001 From: ZisisTsatsas <128043095+ZisisTsatsas@users.noreply.github.com> Date: Mon, 12 Feb 2024 09:17:19 +0000 Subject: [PATCH 8/9] [#1527 #1529] Breaking change to the class renaming of `Kube` service discovery provider (#1954) * Update kubernetes.rst The `Type` field for the kubernetes example is outdated * Rename the class back to `Kube` making it as default K8s provider * Endpoint mocking for `GetAsync()` of `Kube` provider * Switch off timer * Update K8s docs * IDE1006: Naming rule violation * Follow .NET conventions for test class names --------- Co-authored-by: Raman Maksimchuk --- docs/features/kubernetes.rst | 61 ++-- .../EndPointClientV1.cs | 4 +- .../IEndPointClient.cs | 9 + ...tesServiceDiscoveryProvider.cs => Kube.cs} | 11 +- .../KubernetesProviderFactory.cs | 8 +- ...DiscoveryProviderTests.cs => KubeTests.cs} | 280 +++++++++--------- ...overyProviderTests.cs => PollKubeTests.cs} | 4 +- .../ServiceDiscoveryProviderFactoryTests.cs | 63 +++- 8 files changed, 260 insertions(+), 180 deletions(-) create mode 100644 src/Ocelot.Provider.Kubernetes/IEndPointClient.cs rename src/Ocelot.Provider.Kubernetes/{KubernetesServiceDiscoveryProvider.cs => Kube.cs} (78%) rename test/Ocelot.UnitTests/Kubernetes/{KubeServiceDiscoveryProviderTests.cs => KubeTests.cs} (89%) rename test/Ocelot.UnitTests/Kubernetes/{PollingKubeServiceDiscoveryProviderTests.cs => PollKubeTests.cs} (95%) diff --git a/docs/features/kubernetes.rst b/docs/features/kubernetes.rst index a2e17fd08..239640bd2 100644 --- a/docs/features/kubernetes.rst +++ b/docs/features/kubernetes.rst @@ -2,23 +2,27 @@ :alt: K8s Logo :width: 40 -|K8s Logo| Kubernetes -===================== +|K8s Logo| Kubernetes [#f1]_ aka K8s +==================================== - Feature: :doc:`../features/servicediscovery` + A part of feature: :doc:`../features/servicediscovery` -This feature was requested as part of `issue 345 `_ to add support for `Kubernetes `_ service discovery provider. +About [#f2]_ +------------ -Ocelot will call the K8s endpoints API in a given namespace to get all of the endpoints for a pod and then load balance across them. -Ocelot used to use the services API to send requests to the K8s service but this was changed in `PR 1134 `_ because the service did not load balance as expected. +Ocelot will call the `K8s `_ endpoints API in a given namespace to get all of the endpoints for a pod and then load balance across them. +Ocelot used to use the services API to send requests to the `K8s `__ service but this was changed in `PR 1134 `_ because the service did not load balance as expected. -The first thing you need to do is install the `NuGet package `_ that provides Kubernetes support in Ocelot: +Install +------- + +The first thing you need to do is install the `NuGet package `_ that provides **Kubernetes** [#f1]_ support in Ocelot: .. code-block:: powershell Install-Package Ocelot.Provider.Kubernetes -Then add the following to your ConfigureServices method: +Then add the following to your ``ConfigureServices`` method: .. code-block:: csharp @@ -41,21 +45,24 @@ K8s API server and token will read from pod. kubectl create clusterrolebinding permissive-binding --clusterrole=cluster-admin --user=admin --user=kubelet --group=system:serviceaccounts -The following example shows how to set up a Route that will work in Kubernetes. +Configuration +------------- + +The following examples show how to set up a Route that will work in Kubernetes. The most important thing is the **ServiceName** which is made up of the Kubernetes service name. We also need to set up the **ServiceDiscoveryProvider** in **GlobalConfiguration**. + +Kube default provider +^^^^^^^^^^^^^^^^^^^^^ + The example here shows a typical configuration: .. code-block:: json - { "Routes": [ { - "DownstreamPathTemplate": "/api/values", - "DownstreamScheme": "http", - "UpstreamPathTemplate": "/values", "ServiceName": "downstreamservice", - "UpstreamHttpMethod": [ "Get" ] + // ... } ], "GlobalConfiguration": { @@ -63,25 +70,28 @@ The example here shows a typical configuration: "Host": "192.168.0.13", "Port": 443, "Token": "txpc696iUhbVoudg164r93CxDTrKRVWG", - "Namespace": "dev", - "Type": "kube" + "Namespace": "Dev", + "Type": "Kube" } } - } -Service deployment in **Namespace** ``dev``, **ServiceDiscoveryProvider** type is ``kube``, you also can set ``pollkube`` **ServiceDiscoveryProvider** type. +Service deployment in **Namespace** ``Dev``, **ServiceDiscoveryProvider** type is ``Kube``, you also can set :ref:`kubernetes-pollkube-provider` type. Note: **Host**, **Port** and **Token** are no longer in use. +.. kubernetes-pollkube-provider: + +PollKube provider +^^^^^^^^^^^^^^^^^ + You use Ocelot to poll Kubernetes for latest service information rather than per request. If you want to poll Kubernetes for the latest services rather than per request (default behaviour) then you need to set the following configuration: .. code-block:: json "ServiceDiscoveryProvider": { - // ... "Namespace": "dev", - "Type": "pollkube", - "PollingInterval": 100 + "Type": "PollKube", + "PollingInterval": 100 // ms } The polling interval is in milliseconds and tells Ocelot how often to call Kubernetes for changes in service configuration. @@ -92,14 +102,21 @@ This really depends on how volatile your services are. We doubt it will matter for most people and polling may give a tiny performance improvement over calling Kubernetes per request. There is no way for Ocelot to work these out for you. +Global vs Route levels +^^^^^^^^^^^^^^^^^^^^^^ + If your downstream service resides in a different namespace, you can override the global setting at the Route-level by specifying a **ServiceNamespace**: .. code-block:: json "Routes": [ { - // ... "ServiceName": "downstreamservice", "ServiceNamespace": "downstream-namespace" } ] + +"""" + +.. [#f1] `Wikipedia `_ | `K8s Website `_ | `K8s Documentation `_ | `K8s GitHub `_ +.. [#f2] This feature was requested as part of `issue 345 `_ to add support for `Kubernetes `_ :doc:`../features/servicediscovery` provider. diff --git a/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs b/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs index bf1fc815c..22f58e538 100644 --- a/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs +++ b/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs @@ -4,7 +4,7 @@ namespace Ocelot.Provider.Kubernetes { - public class EndPointClientV1 : KubeResourceClient + public class EndPointClientV1 : KubeResourceClient, IEndPointClient { private readonly HttpRequest _collection; @@ -13,7 +13,7 @@ public EndPointClientV1(IKubeApiClient client) : base(client) _collection = KubeRequest.Create("api/v1/namespaces/{Namespace}/endpoints/{ServiceName}"); } - public async Task Get(string serviceName, string kubeNamespace = null, CancellationToken cancellationToken = default) + public async Task GetAsync(string serviceName, string kubeNamespace = null, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(serviceName)) { diff --git a/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs b/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs new file mode 100644 index 000000000..6dfca972d --- /dev/null +++ b/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs @@ -0,0 +1,9 @@ +using KubeClient.Models; +using KubeClient.ResourceClients; + +namespace Ocelot.Provider.Kubernetes; + +public interface IEndPointClient : IKubeResourceClient +{ + Task GetAsync(string serviceName, string kubeNamespace = null, CancellationToken cancellationToken = default); +} diff --git a/src/Ocelot.Provider.Kubernetes/KubernetesServiceDiscoveryProvider.cs b/src/Ocelot.Provider.Kubernetes/Kube.cs similarity index 78% rename from src/Ocelot.Provider.Kubernetes/KubernetesServiceDiscoveryProvider.cs rename to src/Ocelot.Provider.Kubernetes/Kube.cs index 92c7b99cb..15b5cf6cc 100644 --- a/src/Ocelot.Provider.Kubernetes/KubernetesServiceDiscoveryProvider.cs +++ b/src/Ocelot.Provider.Kubernetes/Kube.cs @@ -4,16 +4,19 @@ namespace Ocelot.Provider.Kubernetes; -public class KubernetesServiceDiscoveryProvider : IServiceDiscoveryProvider +/// +/// Default Kubernetes service discovery provider. +/// +public class Kube : IServiceDiscoveryProvider { private readonly KubeRegistryConfiguration _kubeRegistryConfiguration; private readonly IOcelotLogger _logger; private readonly IKubeApiClient _kubeApi; - public KubernetesServiceDiscoveryProvider(KubeRegistryConfiguration kubeRegistryConfiguration, IOcelotLoggerFactory factory, IKubeApiClient kubeApi) + public Kube(KubeRegistryConfiguration kubeRegistryConfiguration, IOcelotLoggerFactory factory, IKubeApiClient kubeApi) { _kubeRegistryConfiguration = kubeRegistryConfiguration; - _logger = factory.CreateLogger(); + _logger = factory.CreateLogger(); _kubeApi = kubeApi; } @@ -21,7 +24,7 @@ public async Task> GetAsync() { var endpoint = await _kubeApi .ResourceClient(client => new EndPointClientV1(client)) - .Get(_kubeRegistryConfiguration.KeyOfServiceInK8s, _kubeRegistryConfiguration.KubeNamespace); + .GetAsync(_kubeRegistryConfiguration.KeyOfServiceInK8s, _kubeRegistryConfiguration.KubeNamespace); var services = new List(); if (endpoint != null && endpoint.Subsets.Any()) diff --git a/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs b/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs index 97d58f5eb..4507c03e6 100644 --- a/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs +++ b/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs @@ -18,17 +18,17 @@ private static IServiceDiscoveryProvider CreateProvider(IServiceProvider provide var factory = provider.GetService(); var kubeClient = provider.GetService(); - var k8SRegistryConfiguration = new KubeRegistryConfiguration + var configuration = new KubeRegistryConfiguration { KeyOfServiceInK8s = route.ServiceName, KubeNamespace = string.IsNullOrEmpty(route.ServiceNamespace) ? config.Namespace : route.ServiceNamespace, }; - var k8SServiceDiscoveryProvider = new KubernetesServiceDiscoveryProvider(k8SRegistryConfiguration, factory, kubeClient); + var defaultK8sProvider = new Kube(configuration, factory, kubeClient); return PollKube.Equals(config.Type, StringComparison.OrdinalIgnoreCase) - ? new PollKube(config.PollingInterval, factory, k8SServiceDiscoveryProvider) - : k8SServiceDiscoveryProvider; + ? new PollKube(config.PollingInterval, factory, defaultK8sProvider) + : defaultK8sProvider; } } } diff --git a/test/Ocelot.UnitTests/Kubernetes/KubeServiceDiscoveryProviderTests.cs b/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs similarity index 89% rename from test/Ocelot.UnitTests/Kubernetes/KubeServiceDiscoveryProviderTests.cs rename to test/Ocelot.UnitTests/Kubernetes/KubeTests.cs index 272128a95..49e91fe6e 100644 --- a/test/Ocelot.UnitTests/Kubernetes/KubeServiceDiscoveryProviderTests.cs +++ b/test/Ocelot.UnitTests/Kubernetes/KubeTests.cs @@ -1,146 +1,146 @@ -using KubeClient; +using KubeClient; using KubeClient.Models; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Newtonsoft.Json; -using Ocelot.Logging; -using Ocelot.Provider.Kubernetes; +using Ocelot.Logging; +using Ocelot.Provider.Kubernetes; using Ocelot.Values; - -namespace Ocelot.UnitTests.Kubernetes -{ - public class KubeServiceDiscoveryProviderTests : IDisposable - { - private IWebHost _fakeKubeBuilder; - private readonly KubernetesServiceDiscoveryProvider _provider; - private EndpointsV1 _endpointEntries; - private readonly string _serviceName; - private readonly string _namespaces; - private readonly int _port; - private readonly string _kubeHost; - private readonly string _fakekubeServiceDiscoveryUrl; - private List _services; - private readonly Mock _factory; - private readonly Mock _logger; - private string _receivedToken; - private readonly IKubeApiClient _clientFactory; - - public KubeServiceDiscoveryProviderTests() - { - _serviceName = "test"; - _namespaces = "dev"; - _port = 5567; - _kubeHost = "localhost"; + +namespace Ocelot.UnitTests.Kubernetes +{ + public class KubeTests : IDisposable + { + private IWebHost _fakeKubeBuilder; + private readonly Kube _provider; + private EndpointsV1 _endpointEntries; + private readonly string _serviceName; + private readonly string _namespaces; + private readonly int _port; + private readonly string _kubeHost; + private readonly string _fakekubeServiceDiscoveryUrl; + private List _services; + private readonly Mock _factory; + private readonly Mock _logger; + private string _receivedToken; + private readonly IKubeApiClient _clientFactory; + + public KubeTests() + { + _serviceName = "test"; + _namespaces = "dev"; + _port = 5567; + _kubeHost = "localhost"; _fakekubeServiceDiscoveryUrl = $"{Uri.UriSchemeHttp}://{_kubeHost}:{_port}"; - _endpointEntries = new EndpointsV1(); - _factory = new Mock(); - - var option = new KubeClientOptions - { - ApiEndPoint = new Uri(_fakekubeServiceDiscoveryUrl), - AccessToken = "txpc696iUhbVoudg164r93CxDTrKRVWG", - AuthStrategy = KubeAuthStrategy.BearerToken, - AllowInsecure = true, - }; - - _clientFactory = KubeApiClient.Create(option); - _logger = new Mock(); - _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); - var config = new KubeRegistryConfiguration - { - KeyOfServiceInK8s = _serviceName, - KubeNamespace = _namespaces, - }; - _provider = new KubernetesServiceDiscoveryProvider(config, _factory.Object, _clientFactory); - } - - [Fact] - public void should_return_service_from_k8s() - { - var token = "Bearer txpc696iUhbVoudg164r93CxDTrKRVWG"; - var endPointEntryOne = new EndpointsV1 - { - Kind = "endpoint", - ApiVersion = "1.0", - Metadata = new ObjectMetaV1 - { - Namespace = "dev", - }, - }; - var endpointSubsetV1 = new EndpointSubsetV1(); - endpointSubsetV1.Addresses.Add(new EndpointAddressV1 - { - Ip = "127.0.0.1", - Hostname = "localhost", - }); - endpointSubsetV1.Ports.Add(new EndpointPortV1 - { - Port = 80, - }); - endPointEntryOne.Subsets.Add(endpointSubsetV1); - - this.Given(x => GivenThereIsAFakeKubeServiceDiscoveryProvider(_fakekubeServiceDiscoveryUrl, _serviceName, _namespaces)) - .And(x => GivenTheServicesAreRegisteredWithKube(endPointEntryOne)) - .When(x => WhenIGetTheServices()) - .Then(x => ThenTheCountIs(1)) - .And(_ => ThenTheTokenIs(token)) - .BDDfy(); - } - - private void ThenTheTokenIs(string token) - { - _receivedToken.ShouldBe(token); - } - - private void ThenTheCountIs(int count) - { - _services.Count.ShouldBe(count); - } - - private void WhenIGetTheServices() - { + _endpointEntries = new EndpointsV1(); + _factory = new Mock(); + + var option = new KubeClientOptions + { + ApiEndPoint = new Uri(_fakekubeServiceDiscoveryUrl), + AccessToken = "txpc696iUhbVoudg164r93CxDTrKRVWG", + AuthStrategy = KubeAuthStrategy.BearerToken, + AllowInsecure = true, + }; + + _clientFactory = KubeApiClient.Create(option); + _logger = new Mock(); + _factory.Setup(x => x.CreateLogger()).Returns(_logger.Object); + var config = new KubeRegistryConfiguration + { + KeyOfServiceInK8s = _serviceName, + KubeNamespace = _namespaces, + }; + _provider = new Kube(config, _factory.Object, _clientFactory); + } + + [Fact] + public void Should_return_service_from_k8s() + { + var token = "Bearer txpc696iUhbVoudg164r93CxDTrKRVWG"; + var endPointEntryOne = new EndpointsV1 + { + Kind = "endpoint", + ApiVersion = "1.0", + Metadata = new ObjectMetaV1 + { + Namespace = "dev", + }, + }; + var endpointSubsetV1 = new EndpointSubsetV1(); + endpointSubsetV1.Addresses.Add(new EndpointAddressV1 + { + Ip = "127.0.0.1", + Hostname = "localhost", + }); + endpointSubsetV1.Ports.Add(new EndpointPortV1 + { + Port = 80, + }); + endPointEntryOne.Subsets.Add(endpointSubsetV1); + + this.Given(x => GivenThereIsAFakeKubeServiceDiscoveryProvider(_fakekubeServiceDiscoveryUrl, _serviceName, _namespaces)) + .And(x => GivenTheServicesAreRegisteredWithKube(endPointEntryOne)) + .When(x => WhenIGetTheServices()) + .Then(x => ThenTheCountIs(1)) + .And(_ => ThenTheTokenIs(token)) + .BDDfy(); + } + + private void ThenTheTokenIs(string token) + { + _receivedToken.ShouldBe(token); + } + + private void ThenTheCountIs(int count) + { + _services.Count.ShouldBe(count); + } + + private void WhenIGetTheServices() + { _services = _provider.GetAsync().GetAwaiter().GetResult(); - } - - private void GivenTheServicesAreRegisteredWithKube(EndpointsV1 endpointEntries) - { - _endpointEntries = endpointEntries; - } - - private void GivenThereIsAFakeKubeServiceDiscoveryProvider(string url, string serviceName, string namespaces) - { - _fakeKubeBuilder = new WebHostBuilder() - .UseUrls(url) - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseIISIntegration() - .UseUrls(url) - .Configure(app => - { - app.Run(async context => - { - if (context.Request.Path.Value == $"/api/v1/namespaces/{namespaces}/endpoints/{serviceName}") - { - if (context.Request.Headers.TryGetValue("Authorization", out var values)) - { - _receivedToken = values.First(); - } - - var json = JsonConvert.SerializeObject(_endpointEntries); - context.Response.Headers.Append("Content-Type", "application/json"); - await context.Response.WriteAsync(json); - } - }); - }) - .Build(); - - _fakeKubeBuilder.Start(); - } - - public void Dispose() - { - _fakeKubeBuilder?.Dispose(); - } - } -} + } + + private void GivenTheServicesAreRegisteredWithKube(EndpointsV1 endpointEntries) + { + _endpointEntries = endpointEntries; + } + + private void GivenThereIsAFakeKubeServiceDiscoveryProvider(string url, string serviceName, string namespaces) + { + _fakeKubeBuilder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .Configure(app => + { + app.Run(async context => + { + if (context.Request.Path.Value == $"/api/v1/namespaces/{namespaces}/endpoints/{serviceName}") + { + if (context.Request.Headers.TryGetValue("Authorization", out var values)) + { + _receivedToken = values.First(); + } + + var json = JsonConvert.SerializeObject(_endpointEntries); + context.Response.Headers.Append("Content-Type", "application/json"); + await context.Response.WriteAsync(json); + } + }); + }) + .Build(); + + _fakeKubeBuilder.Start(); + } + + public void Dispose() + { + _fakeKubeBuilder?.Dispose(); + } + } +} diff --git a/test/Ocelot.UnitTests/Kubernetes/PollingKubeServiceDiscoveryProviderTests.cs b/test/Ocelot.UnitTests/Kubernetes/PollKubeTests.cs similarity index 95% rename from test/Ocelot.UnitTests/Kubernetes/PollingKubeServiceDiscoveryProviderTests.cs rename to test/Ocelot.UnitTests/Kubernetes/PollKubeTests.cs index 94ea8884d..d041288f6 100644 --- a/test/Ocelot.UnitTests/Kubernetes/PollingKubeServiceDiscoveryProviderTests.cs +++ b/test/Ocelot.UnitTests/Kubernetes/PollKubeTests.cs @@ -6,7 +6,7 @@ namespace Ocelot.UnitTests.Kubernetes { - public class PollingKubeServiceDiscoveryProviderTests + public class PollKubeTests { private readonly int _delay; private PollKube _provider; @@ -16,7 +16,7 @@ public class PollingKubeServiceDiscoveryProviderTests private readonly Mock _kubeServiceDiscoveryProvider; private List _result; - public PollingKubeServiceDiscoveryProviderTests() + public PollKubeTests() { _services = new List(); _delay = 1; diff --git a/test/Ocelot.UnitTests/ServiceDiscovery/ServiceDiscoveryProviderFactoryTests.cs b/test/Ocelot.UnitTests/ServiceDiscovery/ServiceDiscoveryProviderFactoryTests.cs index 465e06a2e..88d33f89c 100644 --- a/test/Ocelot.UnitTests/ServiceDiscovery/ServiceDiscoveryProviderFactoryTests.cs +++ b/test/Ocelot.UnitTests/ServiceDiscovery/ServiceDiscoveryProviderFactoryTests.cs @@ -1,7 +1,9 @@ +using KubeClient; using Microsoft.Extensions.DependencyInjection; using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Logging; +using Ocelot.Provider.Kubernetes; using Ocelot.Responses; using Ocelot.ServiceDiscovery; using Ocelot.ServiceDiscovery.Providers; @@ -33,7 +35,7 @@ public ServiceDiscoveryProviderFactoryTests() } [Fact] - public void should_return_no_service_provider() + public void Should_return_no_service_provider() { var serviceConfig = new ServiceProviderConfigurationBuilder() .Build(); @@ -47,7 +49,7 @@ public void should_return_no_service_provider() } [Fact] - public void should_return_list_of_configuration_services() + public void Should_return_list_of_configuration_services() { var serviceConfig = new ServiceProviderConfigurationBuilder() .Build(); @@ -68,7 +70,7 @@ public void should_return_list_of_configuration_services() } [Fact] - public void should_return_provider_because_type_matches_reflected_type_from_delegate() + public void Should_return_provider_because_type_matches_reflected_type_from_delegate() { var route = new DownstreamRouteBuilder() .WithServiceName("product") @@ -87,7 +89,7 @@ public void should_return_provider_because_type_matches_reflected_type_from_dele } [Fact] - public void should_not_return_provider_because_type_doesnt_match_reflected_type_from_delegate() + public void Should_not_return_provider_because_type_doesnt_match_reflected_type_from_delegate() { var route = new DownstreamRouteBuilder() .WithServiceName("product") @@ -106,7 +108,7 @@ public void should_not_return_provider_because_type_doesnt_match_reflected_type_ } [Fact] - public void should_return_service_fabric_provider() + public void Should_return_service_fabric_provider() { var route = new DownstreamRouteBuilder() .WithServiceName("product") @@ -122,8 +124,46 @@ public void should_return_service_fabric_provider() .When(x => x.WhenIGetTheServiceProvider()) .Then(x => x.ThenTheServiceProviderIs()) .BDDfy(); - } + } + + [Theory] + [Trait("Bug", "1954")] + [InlineData("Kube", true)] + [InlineData("kube", true)] + [InlineData("PollKube", true)] + [InlineData("pollkube", true)] + [InlineData("unknown", false)] + public void Should_return_Kubernetes_provider_with_type_names_from_docs(string typeName, bool success) + { + var route = new DownstreamRouteBuilder() + .WithServiceName(nameof(Should_return_Kubernetes_provider_with_type_names_from_docs)) + .WithUseServiceDiscovery(true) + .Build(); + var serviceConfig = new ServiceProviderConfigurationBuilder() + .WithType(typeName) + .WithPollingInterval(Timeout.Infinite) + .Build(); + + this.Given(x => x.GivenTheRoute(serviceConfig, route)) + .And(x => GivenKubernetesProvider()) + .When(x => x.WhenIGetTheServiceProvider()) + .Then(x => EnsureResponse(success)) + .BDDfy(); + } + + private void EnsureResponse(bool success) + { + if (success) + { + _result.ShouldBeOfType>(); + } + else + { + _result.ShouldBeOfType>(); + } + } + private void GivenAFakeDelegate() { ServiceDiscoveryFinderDelegate fake = (provider, config, name) => new Fake(); @@ -132,6 +172,17 @@ private void GivenAFakeDelegate() _factory = new ServiceDiscoveryProviderFactory(_loggerFactory.Object, _provider); } + private void GivenKubernetesProvider() + { + var k8sClient = new Mock(); + _collection + .AddSingleton(KubernetesProviderFactory.Get) + .AddSingleton(k8sClient.Object) + .AddSingleton(_loggerFactory.Object); + _provider = _collection.BuildServiceProvider(); + _factory = new ServiceDiscoveryProviderFactory(_loggerFactory.Object, _provider); + } + private class Fake : IServiceDiscoveryProvider { public Task> GetAsync() From 290fbde84ed4f15bb40edafa87bb23c9bc6d7dbc Mon Sep 17 00:00:00 2001 From: Raman Maksimchuk Date: Tue, 13 Feb 2024 14:29:47 +0300 Subject: [PATCH 9/9] Release 23.0 | Artifacts | +semver: breaking (#1962) * Review & update docs and fix build warnings * Update ReleaseNotes.md: Draft template for build script * Final version of Release Notes --- ReleaseNotes.md | 73 ++++++++++++++++++++++++------ docs/conf.py | 4 +- docs/features/authentication.rst | 26 +++++------ docs/features/caching.rst | 12 ++++- docs/features/kubernetes.rst | 9 ++-- docs/features/routing.rst | 48 +++++++++++--------- docs/features/servicediscovery.rst | 2 + docs/index.rst | 4 +- docs/introduction/gotchas.rst | 9 +++- 9 files changed, 124 insertions(+), 63 deletions(-) diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 460241423..6698eb051 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,20 +1,63 @@ -## Hotfix release (version {0}) -> Default timeout vs the [Quality of Service](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html) feature +## November-December 2023 (version {0}) aka [Sunny Koliada](https://www.google.com/search?q=winter+solstice) release +> Codenamed as **[Sunny Koliada](https://www.bing.com/search?q=winter+solstice)** -Special thanks to **Alvin Huang**, @huanguolin! +### Focus On -### About -The bug is related to the **Quality of Service** feature (aka **QoS**) and the [HttpClient.Timeout](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout) property. -- If JSON `QoSOptions` section is defined in the route config, then the bug is masked rather than active, and the timeout value is assigned from the [QoS TimeoutValue](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html#quality-of-service:~:text=%22TimeoutValue%22%3A%205000) property. -- If the `QoSOptions` section **is not** defined in the route config or the [TimeoutValue](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html#quality-of-service:~:text=%22TimeoutValue%22%3A%205000) property is missing, then the bug is **active** and affects downstream requests that **never time out**. +
+ System performance. System core performance review, redesign of system core related to routing and content streaming -### Technical info -In version [22.0](https://github.com/ThreeMammals/Ocelot/releases/tag/22.0.0), the bug was found in the explicit default constructor of the [FileQoSOptions](https://github.com/ThreeMammals/Ocelot/blob/main/src/Ocelot/Configuration/File/FileQoSOptions.cs) class with a maximum [TimeoutValue](https://github.com/ThreeMammals/Ocelot/blob/main/src/Ocelot/Configuration/File/FileQoSOptions.cs#L9). Previously, the default constructor was implicit with the default assignment of zero `0` to all `int` properties. + - Modification of the `RequestMapper` with a brand new `StreamHttpContent` class, in `Ocelot.Request.Mapper` namespace. The request body is no longer copied when it is handled by the API gateway, avoiding Out-of-Memory issues in pods/containers. This significantly reduces the gateway's memory consumption, and allows you to transfer content larger than 2 GB in streaming scenarios. + - Introduction of a new Message Invoker pool, in `Ocelot.Requester` namespace. We have replaced the [HttpClient](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) class with [HttpMessageInvoker](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpmessageinvoker), which is the base class for `HttpClient`. The overall logic for managing the pool has been simplified, resulting in a reduction in the number of CPU cycles. + - Full HTTP content buffering is deactivated, resulting in a 50% reduction in memory consumption and a performance improvement of around 10%. Content is no longer copied on the API gateway, avoiding Out-of-Memory issues. + - **TODO** Include screenshots from Production... +
-The new explicit default constructor breaks the old implementation of [QoS TimeoutValue](https://github.com/ThreeMammals/Ocelot/blob/main/src/Ocelot/Requester/HttpClientBuilder.cs#L53-L55) logic, as our [QoS documentation](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html#quality-of-service:~:text=If%20you%20do%20not%20add%20a%20QoS%20section%2C%20QoS%20will%20not%20be%20used%2C%20however%20Ocelot%20will%20default%20to%20a%2090%20seconds%20timeout%20on%20all%20downstream%20requests.) states: -![image](https://github.com/ThreeMammals/Ocelot/assets/12430413/2c6b2cd3-e1c6-4510-9e46-883468c140ec)
-**Finally**, the "default 90 second" logic for `HttpClient` breaks down when there are no **QoS** options and all requests on those routes are infinite, if, for example, downstream services are down or stuck. +
+ Ocelot extra packages. Total 3 Ocelot packs were updated + + - [Ocelot.Cache.CacheManager](https://github.com/ThreeMammals/Ocelot/tree/main/src/Ocelot.Cache.CacheManager): Introduced default cache key generator with improved performance (the `DefaultCacheKeyGenerator` class). Old version of `CacheKeyGenerator` had significant performance issue when reading full content of HTTP request for caching key calculation of MD5 hash value. This hash value was excluded from the caching key. + - [Ocelot.Provider.Kubernetes](https://github.com/ThreeMammals/Ocelot/tree/main/src/Ocelot.Provider.Kubernetes): Fixed long lasting breaking change being added in version [15.0.0](https://github.com/ThreeMammals/Ocelot/releases/tag/15.0.0), see commit https://github.com/ThreeMammals/Ocelot/commit/6e5471a714dddb0a3a40fbb97eac2810cee1c78d. The bug persisted for more than 3 years in versions **15.0.0-22.0.1**, being masked multiple times via class renaming! **Special Thanks to @ZisisTsatsas** who once again brought this issue to our attention, and our team finally realized that we had a breaking change and the provider was broken. -#### The Bug Artifacts -- Reported bug issue: [1833](https://github.com/ThreeMammals/Ocelot/issues/1833) by @huanguolin -- Hotfix PR: [1834](https://github.com/ThreeMammals/Ocelot/pull/1834) by @huanguolin + - [Ocelot.Provider.Polly](https://github.com/ThreeMammals/Ocelot/tree/main/src/Ocelot.Provider.Polly): A minor changes without feature delivery. We are preparing for a major update to the package in the next release. +
+ +
+ Middlewares. Total 8 Ocelot middlewares were updated + + - `AuthenticationMiddleware`: Added new [Multiple Authentication Schemes](https://github.com/ThreeMammals/Ocelot/pull/1870) feature by @MayorSheFF + - `OutputCacheMiddleware`, `RequestIdMiddleware`: Added new [Cache by Header Value](https://github.com/ThreeMammals/Ocelot/pull/1172) by @EngRajabi, and redesigned as [Default CacheKeyGenerator](https://github.com/ThreeMammals/Ocelot/pull/1849) feature by @raman-m + - `DownstreamUrlCreatorMiddleware`: Fixed [bug](https://github.com/ThreeMammals/Ocelot/issues/748) for ending/omitting slash in path templates aka [Empty placeholders](https://github.com/ThreeMammals/Ocelot/pull/1911) feature by @AlyHKafoury + - `ConfigurationMiddleware`, `HttpRequesterMiddleware`, `ResponderMiddleware`: System upgrade for [Custom HttpMessageInvoker pooling](https://github.com/ThreeMammals/Ocelot/pull/1824) feature by @ggnaegi + - `DownstreamRequestInitialiserMiddleware`: System upgrade for [Performance of Request Mapper](https://github.com/ThreeMammals/Ocelot/pull/1724) feature by @ggnaegi +
+ +
+ Documentation for Authentication, Caching, Kubernetes and Routing + + - [Authentication](https://ocelot.readthedocs.io/en/latest/features/authentication.html) + - [Caching](https://ocelot.readthedocs.io/en/latest/features/caching.html) + - [Kubernetes](https://ocelot.readthedocs.io/en/latest/features/kubernetes.html) + - [Routing](https://ocelot.readthedocs.io/en/latest/features/routing.html) +
+ +
+ Stabilization aka bug fixing + + - See [all bugs](https://github.com/ThreeMammals/Ocelot/issues?q=is%3Aissue+milestone%3ANov-December%2723+is%3Aclosed+label%3Abug) of the [Nov-December'23](https://github.com/ThreeMammals/Ocelot/milestone/2) milestone +
+ +
+ Testing + + - The `Ocelot.Benchmarks` testing project has been updated with new `PayloadBenchmarks` and `ResponseBenchmarks` by @ggnaegi + - The `Ocelot.AcceptanceTests` testing project has been refactored by @raman-m using the new `AuthenticationSteps` class, and more refactoring will be done in future releases +
+ +### Roadmap +We would like to share our team's plans for the future regarding: development trends, ideas, community expectations, etc. +- **Code Review and Performance Improvements**. Without a doubt, we care about code quality every day, following best development practices. And we review, test, refactor, and redesign features with overall performance in mind. In the next few releases (versions 23.x-24.0) we will take care of: generic providers, multiplexing middleware (Aggregation feature), memory management. +- **Server-Sent Events protocol support**. There is a lot of community interest in this HTTP-based protocol. +- **Long Polling for Consul provider**. [Consul](https://www.consul.io/) is our leading technology for service discovery. We are constantly improving the use cases for the `Ocelot.Provider.Consul` package and trying to improve the code inside the package. +- **QoS feature refactoring**. [Polly](https://github.com/App-vNext/Polly/) was released with the new v.8.2+ after .NET 8. So we have to update `Ocelot.Provider.Polly` package taking into account new Polly behavior of redesigned features. +- **Brainstorming** to redesign Rate Limiting, Websockets. More details in future release notes. +- **Planning** of support for Swagger and gRPC proto. More details in future release notes. diff --git a/docs/conf.py b/docs/conf.py index dca77bb40..ceea7cca2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,9 +7,9 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'Ocelot' -copyright = ' 2023 ThreeMammals Ocelot team' +copyright = ' 2016-2024 ThreeMammals Ocelot team' author = 'Tom Pallister, Ocelot Core team at ThreeMammals' -release = '22.0' +release = '23.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/features/authentication.rst b/docs/features/authentication.rst index 18fb2da97..0c4b8c19d 100644 --- a/docs/features/authentication.rst +++ b/docs/features/authentication.rst @@ -21,13 +21,13 @@ users must register authentication services in their **Startup.cs** as usual but In this example ``MyKey`` is `the scheme `_ that this provider has been registered with. We then map this to a Route in the configuration using the following `AuthenticationOptions `_ properties: -* ``AuthenticationProviderKey`` is a string object, obsolete. [#f1]_ This is legacy definition when you define :ref:`authentication-single` (scheme). +* ``AuthenticationProviderKey`` is a string object, obsolete [#f1]_. This is legacy definition when you define :ref:`authentication-single`. * ``AuthenticationProviderKeys`` is an array of strings, the recommended definition of :ref:`authentication-multiple` feature. -.. authentication-single: +.. _authentication-single: -Single Key [#f1]_ ------------------ +Single Key aka Authentication Scheme [#f1]_ +------------------------------------------- | Property: ``AuthenticationOptions.AuthenticationProviderKey`` @@ -46,7 +46,7 @@ If there isn't then Ocelot will not start up. If there is then the Route will us If a Route is authenticated, Ocelot will invoke whatever scheme is associated with it while executing the authentication middleware. If the request fails authentication, Ocelot returns a HTTP status code `401 Unauthorized `_. -.. authentication-multiple: +.. _authentication-multiple: Multiple Authentication Schemes [#f2]_ -------------------------------------- @@ -55,7 +55,7 @@ Multiple Authentication Schemes [#f2]_ In real world of ASP.NET, apps may need to support multiple types of authentication by single Ocelot app instance. To register `multiple authentication schemes `_ -(authentication provider keys) for each appropriate authentication provider, use and develop this abstract configuration of two or more schemes: +(`authentication provider keys `_) for each appropriate authentication provider, use and develop this abstract configuration of two or more schemes: .. code-block:: csharp @@ -68,8 +68,8 @@ To register `multiple authentication schemes { /* Custom auth setup */ }); } -In this example, the schemes ``MyKey`` and ``Bearer`` represent the keys which these providers have been registered with. -We then map these schemes to a Route in the configuration, as shown below +In this example, the ``MyKey`` and ``Bearer`` schemes represent the keys with which these providers were registered. +We then map these schemes to a Route in the configuration as shown below. .. code-block:: json @@ -83,9 +83,8 @@ Afterward, Ocelot applies all steps that are specified for ``AuthenticationProvi **Note** that the order of the keys in an array definition does matter! We use a "First One Wins" authentication strategy. Finally, we would say that registering providers, initializing options, forwarding authentication artifacts can be a "real" coding challenge. -If you're stuck or don't know what to do, just find inspiration in our `acceptance tests `_ -(currently for `Identity Server 4 `_ only). -We would appreciate any new PRs to add extra acceptance tests for your custom scenarios with `multiple authentication schemes `__. [#f2]_ +If you're stuck or don't know what to do, just find inspiration in our `acceptance tests `_ +(currently for `Identity Server 4 `_ only) [#f3]_. JWT Tokens ---------- @@ -210,5 +209,6 @@ Please, open `Show and tell `__ feature was requested in issues `740 `_, `1580 `_ and delivered as a part of `23.0 `_ release. +.. [#f1] Use the ``AuthenticationProviderKeys`` property instead of ``AuthenticationProviderKey`` one. We support this ``[Obsolete]`` property for backward compatibility and migration reasons. In future releases, the property may be removed as a breaking change. +.. [#f2] "`Multiple authentication schemes `__" feature was requested in issues `740 `_, `1580 `_ and delivered as a part of `23.0 `_ release. +.. [#f3] We would appreciate any new PRs to add extra acceptance tests for your custom scenarios with `multiple authentication schemes `__. diff --git a/docs/features/caching.rst b/docs/features/caching.rst index f7118fe83..f2bf302ed 100644 --- a/docs/features/caching.rst +++ b/docs/features/caching.rst @@ -6,6 +6,9 @@ This is an amazing project that is solving a lot of caching problems. We would r The following example shows how to add **CacheManager** to Ocelot so that you can do output caching. +Install +------- + First of all, add the following `NuGet package `_: .. code-block:: powershell @@ -26,11 +29,18 @@ The second thing you need to do something like the following to your ``Configure .AddCacheManager(x => x.WithDictionaryHandle()); }); +Configuration +------------- + Finally, in order to use caching on a route in your Route configuration add this setting: .. code-block:: json - "FileCacheOptions": { "TtlSeconds": 15, "Region": "europe-central", "Header": "Authorization" } + "FileCacheOptions": { + "TtlSeconds": 15, + "Region": "europe-central", + "Header": "Authorization" + } In this example **TtlSeconds** is set to 15 which means the cache will expire after 15 seconds. The **Region** represents a region of caching. diff --git a/docs/features/kubernetes.rst b/docs/features/kubernetes.rst index 239640bd2..44411e589 100644 --- a/docs/features/kubernetes.rst +++ b/docs/features/kubernetes.rst @@ -5,10 +5,7 @@ |K8s Logo| Kubernetes [#f1]_ aka K8s ==================================== - A part of feature: :doc:`../features/servicediscovery` - -About [#f2]_ ------------- + A part of feature: :doc:`../features/servicediscovery` [#f2]_ Ocelot will call the `K8s `_ endpoints API in a given namespace to get all of the endpoints for a pod and then load balance across them. Ocelot used to use the services API to send requests to the `K8s `__ service but this was changed in `PR 1134 `_ because the service did not load balance as expected. @@ -75,10 +72,10 @@ The example here shows a typical configuration: } } -Service deployment in **Namespace** ``Dev``, **ServiceDiscoveryProvider** type is ``Kube``, you also can set :ref:`kubernetes-pollkube-provider` type. +Service deployment in **Namespace** ``Dev``, **ServiceDiscoveryProvider** type is ``Kube``, you also can set :ref:`k8s-pollkube-provider` type. Note: **Host**, **Port** and **Token** are no longer in use. -.. kubernetes-pollkube-provider: +.. _k8s-pollkube-provider: PollKube provider ^^^^^^^^^^^^^^^^^ diff --git a/docs/features/routing.rst b/docs/features/routing.rst index f7fdd20c8..6de03d8e2 100644 --- a/docs/features/routing.rst +++ b/docs/features/routing.rst @@ -73,8 +73,8 @@ This means that when Ocelot tries to match the incoming upstream URL with an ups .. _routing-empty-placeholders: -Empty Placeholders -^^^^^^^^^^^^^^^^^^ +Empty Placeholders [#f1]_ +^^^^^^^^^^^^^^^^^^^^^^^^^ This is a special edge case of :ref:`routing-placeholders`, where the value of the placeholder is simply an empty string ``""``. @@ -96,9 +96,6 @@ For example, **Given a route**: * Also, it works when ``{url}`` is empty. We would expect upstream path ``/invoices/`` to route to downstream path ``/api/invoices/`` * Moreover, it should work when omitting last slash. We also expect upstream ``/invoices`` to be routed to downstream ``/api/invoices``, which is intuitive to humans -This feature is available starting from Ocelot version `23.0 `_, -see more in issue `748 `_ and release `23.0 `__ notes. - .. _routing-catch-all: Catch All @@ -136,8 +133,10 @@ If you also have the Route below in your config then Ocelot would match it befor ] } -Upstream Host -------------- +.. _routing-upstream-host: + +Upstream Host [#f2]_ +-------------------- This feature allows you to have Routes based on the *upstream host*. This works by looking at the ``Host`` header the client has used and then using this as part of the information we use to identify a Route. @@ -155,8 +154,6 @@ The Route above will only be matched when the ``Host`` header value is ``somedom If you do not set **UpstreamHost** on a Route then any ``Host`` header will match it. This means that if you have two Routes that are the same, apart from the **UpstreamHost**, where one is null and the other set Ocelot will favour the one that has been set. -This feature was requested as part of `issue 216 `_. - Priority -------- @@ -193,18 +190,10 @@ and In the example above if you make a request into Ocelot on ``/goods/delete``, Ocelot will match ``/goods/delete`` Route. Previously it would have matched ``/goods/{catchAll}``, because this is the first Route in the list! -Dynamic Routing ---------------- - -This feature was requested in `issue 340 `_. - -The idea is to enable dynamic routing when using a service discovery provider so you don't have to provide the Route config. -See the docs :doc:`../features/servicediscovery` if this sounds interesting to you. - Query String Placeholders ------------------------- -In addition to URL path `placeholders <#placeholders>`_ Ocelot is able to forward query string parameters with their processing in the form of ``{something}``. +In addition to URL path :ref:`routing-placeholders` Ocelot is able to forward query string parameters with their processing in the form of ``{something}``. Also, the query parameter placeholder needs to be present in both the **DownstreamPathTemplate** and **UpstreamPathTemplate** properties. Placeholder replacement works bi-directionally between path and query strings, with some `restrictions <#restrictions-on-use>`_ on usage. @@ -256,7 +245,7 @@ The placeholder ``{everything}`` name does not matter, any name will work. This entire query string routing feature is very useful in cases where the query string should not be transformed but rather routed without any changes, such as OData filters and etc (see issue `1174 `_). -**Note**, the ``{everything}`` placeholder can be empty while catching all query strings, because this is a part of the :ref:`routing-empty-placeholders` feature! +**Note**, the ``{everything}`` placeholder can be empty while catching all query strings, because this is a part of the :ref:`routing-empty-placeholders` feature! [#f1]_ Thus, upstream paths ``/contracts?`` and ``/contracts`` are routed to downstream path ``/apipath/contracts``, which has no query string at all. Restrictions on use @@ -303,8 +292,10 @@ Here are two user scenarios. So, both ``{userId}`` placeholder and ``userId`` parameter **names are the same**! Finally, the ``userId`` parameter is removed. -Security Options ----------------- +.. _routing-security-options: + +Security Options [#f3]_ +----------------------- Ocelot allows you to manage multiple patterns for allowed/blocked IPs using the `IPAddressRange `_ package with `MPL-2.0 License `_. @@ -333,4 +324,17 @@ The current patterns managed are the following: } } -This feature was requested as part of `issue 1400 `_. +.. _routing-dynamic: + +Dynamic Routing [#f4]_ +---------------------- + +The idea is to enable dynamic routing when using a :doc:`../features/servicediscovery` provider so you don't have to provide the Route config. +See the :ref:`sd-dynamic-routing` docs if this sounds interesting to you. + +"""" + +.. [#f1] ":ref:`routing-empty-placeholders`" feature is available starting in version `23.0 `_, see issue `748 `_ and the `23.0 `__ release notes for details. +.. [#f2] ":ref:`routing-upstream-host`" feature was requested as part of `issue 216 `_. +.. [#f3] ":ref:`routing-security-options`" feature was requested as part of `issue 628 `_ (of `12.0.1 `_ version), then redesigned and improved by `issue 1400 `_, and published in version `20.0 `_ docs. +.. [#f4] ":ref:`routing-dynamic`" feature was requested as part of `issue 340 `_. Complete reference: :ref:`sd-dynamic-routing`. diff --git a/docs/features/servicediscovery.rst b/docs/features/servicediscovery.rst index 9089240e8..6d9d8d4ef 100644 --- a/docs/features/servicediscovery.rst +++ b/docs/features/servicediscovery.rst @@ -188,6 +188,8 @@ When Ocelot asks for a given service it is retrieved from memory so performance Ocelot will use the scheme (``http``, ``https``) set in Eureka if these values are not provided in **ocelot.json** +.. _sd-dynamic-routing: + Dynamic Routing --------------- diff --git a/docs/index.rst b/docs/index.rst index 87d0afa5f..acdb21b46 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,5 +1,5 @@ -Welcome to Ocelot 22.0 -====================== +Welcome to Ocelot `23.0 `_ +====================================================================================== Thanks for taking a look at the Ocelot documentation! Please use the left hand navigation to get around. The team would suggest taking a look at the **Introduction** chapter first. diff --git a/docs/introduction/gotchas.rst b/docs/introduction/gotchas.rst index ec744ea09..eec801a61 100644 --- a/docs/introduction/gotchas.rst +++ b/docs/introduction/gotchas.rst @@ -35,12 +35,12 @@ Kestrel We **do** recommend to deploy Ocelot app to self-hosting environments, aka Kestrel vs Docker. We try to optimize Ocelot web app for Kestrel & Docker hosting scenarios, but keep in mind the following gotchas. -* **Upload and download large files**, proxying the content through the gateway. It is strange when you pump large (static) files using the gateway. +* **Upload and download large files** [#f1]_, proxying the content through the gateway. It is strange when you pump large (static) files using the gateway. We believe that your client apps should have direct integration to (static) files persistent storages and services: remote & destributed file systems, CDNs, static files & blob storages, etc. We **do not** recommend to pump large files (100Mb+ or even larger 1GB+) using gateway because of performance reasons: consuming memory and CPU, long delay times, producing network errors for downstream streaming, impact on other routes. | The community constanly reports issues related to `large files `_, ``application/octet-stream`` content type, :ref:`chunked-encoding`, etc., see issues `749 `_, `1472 `_. - | If you still want to pump large files through an Ocelot gateway instance, we believe our PRs `1724 `_, `1769 `_ will help resolve the issues and stabilize large content proxying. + | If you still want to pump large files through an Ocelot gateway instance, use `23.0 `_ version and higher [#f1]_. | In case of some errors, see the next point. * **Maximum request body size**. ASP.NET ``HttpRequest`` behaves erroneously for application instances that do not have their Kestrel `MaxRequestBodySize `_ option configured correctly and having pumped large files of unpredictable size which exceeds the limit. @@ -59,3 +59,8 @@ We try to optimize Ocelot web app for Kestrel & Docker hosting scenarios, but ke }); Hope it helps. + + +"""" + +.. [#f1] Large files pumping is stabilized and available as complete solution starting in `23.0 `__ release. We believe our PRs `1724 `_, `1769 `_ helped to resolve the issues and stabilize large content proxying problems of `22.0.1 `_ version and lower.