diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 6698eb051..264b2068c 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,63 +1,39 @@ -## 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)** +## January 2024 (version {0}) aka [Hornussen](https://www.myswitzerland.com/en-ch/planning/about-switzerland/custom-and-tradition/hornussen-where-the-nouss-flies-from-the-ramp-and-into-the-playing-field/) release +> Codenamed as **[Hornussen Sport](https://www.youtube.com/results?search_query=Hornussen)** +> Read the Docs: [Ocelot 23.1](https://ocelot.readthedocs.io/en/23.1.0/) ### Focus On
- System performance. System core performance review, redesign of system core related to routing and content streaming - - - 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... + Multiplexing middleware aka Request Aggregation feature + +- Significant refactoring and design review of the [Multiplexer](https://github.com/ThreeMammals/Ocelot/tree/develop/src/Ocelot/Multiplexer) +- Optimizing multiplexer performance: `HttpContext` is not copied when there is only one downstream route, and etc. +- Fixed [the bug](https://github.com/ThreeMammals/Ocelot/pull/1462) in the multiplexer: `HttpContext.User` information was not copied if there was more than one downstream request.
- 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. + System routing. Content streaming when Transfer-Encoding: 'chunked' - - [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. + - Correction of [the bug](https://github.com/ThreeMammals/Ocelot/pull/1972) when creating requests: The header [Transfer-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding): `chunked` was present even when there was no content or the request body size was 0. These cases are now addressed.
- Middlewares. Total 8 Ocelot middlewares were updated + Updates of the features: QoS, Load Balancer and Error Status Codes - - `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 +- [Quality of Service](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html): Possibility of implementation of custom Polly v8.2 providers. New `AddPolly` extension methods. +- [Load Balancer](https://ocelot.readthedocs.io/en/latest/features/loadbalancer.html): Extension of the route key format, ensuring that the key remains unique for cases of **UpstreamHost** route property and **ServiceName** vs **ServiceNamespace** properties in Consul setup. +- [Error Status Codes](https://ocelot.readthedocs.io/en/latest/features/errorcodes.html): When [413 Content Too Large](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413), Ocelot now returns a 413 `PayloadTooLargeError` (Ocelot error code `41`).
- Documentation for Authentication, Caching, Kubernetes and Routing + Documentation for Request Aggregation - - [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) + - [Request Aggregation](https://ocelot.readthedocs.io/en/latest/features/requestaggregation.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 + - See [all bugs](https://github.com/ThreeMammals/Ocelot/issues?q=is%3Aissue+is%3Aclosed+label%3Abug+milestone%3AJanuary%2724) of the [January'24](https://github.com/ThreeMammals/Ocelot/milestone/4) milestone
- -### 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 ceea7cca2..b26c8b795 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,7 +9,7 @@ project = 'Ocelot' copyright = ' 2016-2024 ThreeMammals Ocelot team' author = 'Tom Pallister, Ocelot Core team at ThreeMammals' -release = '23.0' +release = '23.1' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/features/requestaggregation.rst b/docs/features/requestaggregation.rst index a07e7f982..b664a2a74 100644 --- a/docs/features/requestaggregation.rst +++ b/docs/features/requestaggregation.rst @@ -1,24 +1,105 @@ -Request Aggregation -=================== +Request Aggregation [#f1]_ +========================== Ocelot allows you to specify Aggregate Routes that compose multiple normal Routes and map their responses into one object. This is usually where you have a client that is making multiple requests to a server where it could just be one. -This feature allows you to start implementing back-end for a front-end (BFF) type architecture with Ocelot. - -This feature was requested as part of `issue 79 `_ and further improvements were made as part of `issue 298 `_. +This feature allows you to start implementing back-end for a front-end (BFF) type architecture with Ocelot. [#f1]_ In order to set this up you must do something like the following in your **ocelot.json**. Here we have specified two normal Routes and each one has a **Key** property. We then specify an Aggregate that composes the two Routes using their keys in the **RouteKeys** list and says then we have the **UpstreamPathTemplate** which works like a normal Route. Obviously you cannot have duplicate **UpstreamPathTemplates** between **Routes** and **Aggregates**. -You can use all of Ocelot's normal Route options apart from **RequestIdKey** (explained in `gotchas <#gotchas>`_ below). +You can use all of Ocelot's normal Route options apart from **RequestIdKey** (explained in :ref:`agg-gotchas` below). + +Basic Expecting JSON from Downstream Services +--------------------------------------------- + +.. code-block:: json + + { + "Routes": [ + { + "UpstreamHttpMethod": [ "Get" ], + "UpstreamPathTemplate": "/laura", + "DownstreamPathTemplate": "/", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 51881 } + ], + "Key": "Laura" + }, + { + "UpstreamHttpMethod": [ "Get" ], + "UpstreamPathTemplate": "/tom", + "DownstreamPathTemplate": "/", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 51882 } + ], + "Key": "Tom" + } + ], + "Aggregates": [ + { + "UpstreamPathTemplate": "/", + "RouteKeys": [ "Tom", "Laura" ] + } + ] + } + +You can also set **UpstreamHost** and **RouteIsCaseSensitive** in the Aggregate configuration. These behave the same as any other Routes. + +If the Route ``/tom`` returned a body of ``{"Age": 19}`` and ``/laura`` returned ``{"Age": 25}``, the the response after aggregation would be as follows: + +.. code-block:: json + + {"Tom":{"Age": 19},"Laura":{"Age": 25}} + +At the moment the aggregation is very simple. Ocelot just gets the response from your downstream service and sticks it into a JSON dictionary as above. +With the Route key being the key of the dictionary and the value the response body from your downstream service. +You can see that the object is just JSON without any pretty spaces etc. + +Note, all headers will be lost from the downstream services response. + +Ocelot will always return content type ``application/json`` with an aggregate request. + +If you downstream services return a `404 Not Found `_, the aggregate will just return nothing for that downstream service. +It will not change the aggregate response into a ``404`` even if all the downstreams return a ``404``. + +Use Complex Aggregation +----------------------- + +Imagine you'd like to use aggregated queries, but you don't know all the parameters of your queries. You first need to call an endpoint to obtain the necessary data, for example a user's id, and then return the user's details. + +Let's say we have an endpoint that returns a series of comments with references to various users or threads. The author of the comments is referenced by his Id, but you'd like to return all the details about the author. + +Here, you could use aggregation to get 1) all the comments, 2) attach the author details. In fact there are 2 endpoints that are called, but for the 2nd, you dynamically replace the user's Id in the route to obtain the details. + +In concrete terms: -Advanced Register Your Own Aggregators --------------------------------------- +1) ``/Comments`` contains the authorId property +2) ``/users/{userId}`` with ``{userId}`` replaced by **authorId** to obtain the user's details. + +This functionality is still in its early stages, but it does allow you to search for data based on an initial request. + +To perform the mapping, you need to use **AggregateRouteConfig**: + +.. code-block:: csharp + + new AggregateRouteConfig + { + RouteKey = "UserDetails", + JsonPath = "$[*].authorId", + Parameter = "userId" + }; + +**RouteKey** is used as a reference for the route, **JsonPath** indicates where the parameter you are interested in is located in the first request response body and **Parameter** tells us that the value for ``authorId`` should be used for the request parameter ``userId``. + +Register Your Own Aggregators +----------------------------- Ocelot started with just the basic request aggregation and since then we have added a more advanced method that let's the user take in the responses from the downstream services and then aggregate them into a response object. - The **ocelot.json** setup is pretty much the same as the basic aggregation approach apart from you need to add an **Aggregator** property like below: .. code-block:: json @@ -60,7 +141,7 @@ The **ocelot.json** setup is pretty much the same as the basic aggregation appro Here we have added an aggregator called ``FakeDefinedAggregator``. Ocelot is going to look for this aggregator when it tries to aggregate this Route. -In order to make the aggregator available we must add the ``FakeDefinedAggregator`` to the ``OcelotBuilder`` being returned by ``AddOcelot()`` [#f1]_ like below: +In order to make the aggregator available we must add the ``FakeDefinedAggregator`` to the ``OcelotBuilder`` being returned by ``AddOcelot()`` [#f2]_ like below: .. code-block:: csharp @@ -98,66 +179,40 @@ In order to make an Aggregator you must implement this interface: } With this feature you can pretty much do whatever you want because the ``HttpContext`` objects contain the results of all the aggregate requests. -Please note, if the ``HttpClient`` throws an exception when making a request to a Route in the aggregate then you will not get a ``HttpContext`` for it, but you would for any that succeed. -If it does throw an exception, this will be logged. -Basic Expecting JSON from Downstream Services ---------------------------------------------- +Please note, if the ``HttpClient`` throws an exception when making a request to a Route in the aggregate then you will not get a ``HttpContext`` for it, but you would for any that succeed. If it does throw an exception, this will be logged. -.. code-block:: json +Below is an example of an aggregator that you could implement for your solution: +.. code-block:: csharp + + public class FakeDefinedAggregator : IDefinedAggregator { - "Routes": [ - { - "UpstreamHttpMethod": [ "Get" ], - "UpstreamPathTemplate": "/laura", - "DownstreamPathTemplate": "/", - "DownstreamScheme": "http", - "DownstreamHostAndPorts": [ - { "Host": "localhost", "Port": 51881 } - ], - "Key": "Laura" - }, - { - "UpstreamHttpMethod": [ "Get" ], - "UpstreamPathTemplate": "/tom", - "DownstreamPathTemplate": "/", - "DownstreamScheme": "http", - "DownstreamHostAndPorts": [ - { "Host": "localhost", "Port": 51882 } - ], - "Key": "Tom" - } - ], - "Aggregates": [ + public async Task Aggregate(List responseHttpContexts) { - "UpstreamPathTemplate": "/", - "RouteKeys": [ - "Tom", - "Laura" - ] + // The aggregator gets a list of downstream responses as parameter. + // You can now implement your own logic to aggregate the responses (including bodies and headers) from the downstream services + var responses = responseHttpContexts.Select(x => x.Items.DownstreamResponse()).ToArray(); + + // In this example we are concatenating the results, + // but you could create a more complex construct, up to you. + var contentList = new List(); + foreach (var response in responses) + { + var content = await response.Content.ReadAsStringAsync(); + contentList.Add(content); + } + + // The only constraint here: You must return a DownstreamResponse object. + return new DownstreamResponse( + new StringContent(JsonConvert.SerializeObject(contentList)), + HttpStatusCode.OK, + responses.SelectMany(x => x.Headers).ToList(), + "reason"); } - ] } -You can also set **UpstreamHost** and **RouteIsCaseSensitive** in the Aggregate configuration. These behave the same as any other Routes. - -If the Route ``/tom`` returned a body of ``{"Age": 19}`` and ``/laura`` returned ``{"Age": 25}``, the the response after aggregation would be as follows: - -.. code-block:: json - - {"Tom":{"Age": 19},"Laura":{"Age": 25}} - -At the moment the aggregation is very simple. Ocelot just gets the response from your downstream service and sticks it into a JSON dictionary as above. -With the Route key being the key of the dictionary and the value the response body from your downstream service. -You can see that the object is just JSON without any pretty spaces etc. - -Note, all headers will be lost from the downstream services response. - -Ocelot will always return content type ``application/json`` with an aggregate request. - -If you downstream services return a `404 Not Found `_, the aggregate will just return nothing for that downstream service. -It will not change the aggregate response into a ``404`` even if all the downstreams return a ``404``. +.. _agg-gotchas: Gotchas ------- @@ -168,4 +223,5 @@ Aggregation only supports the ``GET`` HTTP verb. """" -.. [#f1] The ``AddOcelot`` method adds default ASP.NET services to DI-container. You could call another more extended ``AddOcelotUsingBuilder`` method while configuring services to build and use custom builder via an ``IMvcCoreBuilder`` interface object. See more instructions in :doc:`../features/dependencyinjection`, "**The AddOcelotUsingBuilder method**" section. +.. [#f1] This feature was requested as part of `issue 79 `_ and further improvements were made as part of `issue 298 `_. A significant refactoring and revision of the `Multiplexer `_ design was carried out on March 4, 2024 in version `23.1 `_, see PRs `1826 `_ and `1462 `_. +.. [#f2] The ``AddOcelot`` method adds default ASP.NET services to DI-container. You could call another more extended ``AddOcelotUsingBuilder`` method while configuring services to build and use custom builder via an ``IMvcCoreBuilder`` interface object. See more instructions in :doc:`../features/dependencyinjection`, "**The AddOcelotUsingBuilder method**" section. diff --git a/docs/index.rst b/docs/index.rst index acdb21b46..0247a8ad4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,4 +1,4 @@ -Welcome to Ocelot `23.0 `_ +Welcome to Ocelot `23.1 `_ ====================================================================================== Thanks for taking a look at the Ocelot documentation! Please use the left hand navigation to get around. diff --git a/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs b/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs index a12633683..8bc014269 100644 --- a/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs +++ b/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs @@ -1,44 +1,115 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Ocelot.Configuration; -using Ocelot.DependencyInjection; -using Ocelot.Errors; -using Ocelot.Errors.QoS; -using Ocelot.Logging; -using Ocelot.Provider.Polly.Interfaces; -using Ocelot.Requester; -using Polly.CircuitBreaker; -using Polly.Timeout; - -namespace Ocelot.Provider.Polly; - -public static class OcelotBuilderExtensions -{ - public static IOcelotBuilder AddPolly(this IOcelotBuilder builder, - QosDelegatingHandlerDelegate delegatingHandler, - Dictionary> errorMapping) - where T : class, IPollyQoSProvider - { - builder.Services - .AddSingleton(errorMapping) - .AddSingleton, T>() - .AddSingleton(delegatingHandler); - - return builder; - } - - public static IOcelotBuilder AddPolly(this IOcelotBuilder builder) - { - var errorMapping = new Dictionary> - { - { typeof(TaskCanceledException), e => new RequestTimedOutError(e) }, - { typeof(TimeoutRejectedException), e => new RequestTimedOutError(e) }, - { typeof(BrokenCircuitException), e => new RequestTimedOutError(e) }, - { typeof(BrokenCircuitException), e => new RequestTimedOutError(e) }, - }; - return AddPolly(builder, GetDelegatingHandler, errorMapping); - } - - private static DelegatingHandler GetDelegatingHandler(DownstreamRoute route, IHttpContextAccessor contextAccessor, IOcelotLoggerFactory loggerFactory) - => new PollyPoliciesDelegatingHandler(route, contextAccessor, loggerFactory); +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Ocelot.Configuration; +using Ocelot.DependencyInjection; +using Ocelot.Errors; +using Ocelot.Errors.QoS; +using Ocelot.Logging; +using Ocelot.Provider.Polly.Interfaces; +using Ocelot.Requester; +using Polly.CircuitBreaker; +using Polly.Timeout; + +namespace Ocelot.Provider.Polly; + +public static class OcelotBuilderExtensions +{ + /// + /// Default mapping of Polly s to objects. + /// + public static readonly Dictionary> DefaultErrorMapping = new Dictionary> + { + {typeof(TaskCanceledException), CreateRequestTimedOutError}, + {typeof(TimeoutRejectedException), CreateRequestTimedOutError}, + {typeof(BrokenCircuitException), CreateRequestTimedOutError}, + {typeof(BrokenCircuitException), CreateRequestTimedOutError}, + }; + + private static Error CreateRequestTimedOutError(Exception e) => new RequestTimedOutError(e); + + /// + /// Adds Polly QoS provider to Ocelot by custom delegate and with custom error mapping. + /// + /// QoS provider to use (by default use ). + /// Ocelot builder to extend. + /// Your customized delegating handler (to manage QoS behavior by yourself). + /// Your customized error mapping. + /// The reference to the same extended object. + public static IOcelotBuilder AddPolly(this IOcelotBuilder builder, + QosDelegatingHandlerDelegate delegatingHandler, + Dictionary> errorMapping) + where T : class, IPollyQoSProvider + { + builder.Services + .AddSingleton(errorMapping) + .AddSingleton, T>() + .AddSingleton(delegatingHandler); + + return builder; + } + + /// + /// Adds Polly QoS provider to Ocelot with custom error mapping, but default is used. + /// + /// QoS provider to use (by default use ). + /// Ocelot builder to extend. + /// Your customized error mapping. + /// The reference to the same extended object. + public static IOcelotBuilder AddPolly(this IOcelotBuilder builder, Dictionary> errorMapping) + where T : class, IPollyQoSProvider => + AddPolly(builder, DefaultDelegatingHandler, errorMapping); + + /// + /// Adds Polly QoS provider to Ocelot with custom delegate, but default error mapping is used. + /// + /// QoS provider to use (by default use ). + /// Ocelot builder to extend. + /// Your customized delegating handler (to manage QoS behavior by yourself). + /// The reference to the same extended object. + public static IOcelotBuilder AddPolly(this IOcelotBuilder builder, QosDelegatingHandlerDelegate delegatingHandler) + where T : class, IPollyQoSProvider => + AddPolly(builder, delegatingHandler, DefaultErrorMapping); + + /// + /// Adds Polly QoS provider to Ocelot by defaults. + /// + /// + /// Defaults: + /// + /// + /// + /// + /// + /// QoS provider to use (by default use ). + /// Ocelot builder to extend. + /// The reference to the same extended object. + public static IOcelotBuilder AddPolly(this IOcelotBuilder builder) + where T : class, IPollyQoSProvider => + AddPolly(builder, DefaultDelegatingHandler, DefaultErrorMapping); + + /// + /// Adds Polly QoS provider to Ocelot by defaults with default QoS provider. + /// + /// + /// Defaults: + /// + /// + /// + /// + /// + /// + /// Ocelot builder to extend. + /// The reference to the same extended object. + public static IOcelotBuilder AddPolly(this IOcelotBuilder builder) => + AddPolly(builder, DefaultDelegatingHandler, DefaultErrorMapping); + + /// + /// Creates default delegating handler based on the type. + /// + /// The downstream route to apply the handler for. + /// The context accessor of the route. + /// The factory of logger. + /// A object, but concreate type is the class. + public static DelegatingHandler DefaultDelegatingHandler(DownstreamRoute route, IHttpContextAccessor contextAccessor, IOcelotLoggerFactory loggerFactory) + => new PollyPoliciesDelegatingHandler(route, contextAccessor, loggerFactory); } diff --git a/src/Ocelot/Configuration/Creator/AggregatesCreator.cs b/src/Ocelot/Configuration/Creator/AggregatesCreator.cs index 6743edfc7..9a5ae2906 100644 --- a/src/Ocelot/Configuration/Creator/AggregatesCreator.cs +++ b/src/Ocelot/Configuration/Creator/AggregatesCreator.cs @@ -24,16 +24,15 @@ private Route SetUpAggregateRoute(IEnumerable routes, FileAggregateRoute { var applicableRoutes = new List(); var allRoutes = routes.SelectMany(x => x.DownstreamRoute); - - foreach (var routeKey in aggregateRoute.RouteKeys) - { - var selec = allRoutes.FirstOrDefault(q => q.Key == routeKey); - if (selec == null) - { - return null; - } - - applicableRoutes.Add(selec); + var downstreamRoutes = aggregateRoute.RouteKeys.Select(routeKey => allRoutes.FirstOrDefault(q => q.Key == routeKey)); + foreach (var downstreamRoute in downstreamRoutes) + { + if (downstreamRoute == null) + { + return null; + } + + applicableRoutes.Add(downstreamRoute); } var upstreamTemplatePattern = _creator.Create(aggregateRoute); diff --git a/src/Ocelot/Configuration/Creator/RouteKeyCreator.cs b/src/Ocelot/Configuration/Creator/RouteKeyCreator.cs index 93ff6f0aa..8bc5debc9 100644 --- a/src/Ocelot/Configuration/Creator/RouteKeyCreator.cs +++ b/src/Ocelot/Configuration/Creator/RouteKeyCreator.cs @@ -1,17 +1,86 @@ using Ocelot.Configuration.File; -using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.LoadBalancer.LoadBalancers; -namespace Ocelot.Configuration.Creator -{ - public class RouteKeyCreator : IRouteKeyCreator +namespace Ocelot.Configuration.Creator; + +public class RouteKeyCreator : IRouteKeyCreator +{ + /// + /// Creates the unique key based on the route properties for load balancing etc. + /// + /// + /// Key template: + /// + /// UpstreamHttpMethod|UpstreamPathTemplate|UpstreamHost|DownstreamHostAndPorts|ServiceNamespace|ServiceName|LoadBalancerType|LoadBalancerKey + /// + /// + /// The route object. + /// A object containing the key. + public string Create(FileRoute fileRoute) { - public string Create(FileRoute fileRoute) => IsStickySession(fileRoute) - ? $"{nameof(CookieStickySessions)}:{fileRoute.LoadBalancerOptions.Key}" - : $"{fileRoute.UpstreamPathTemplate}|{string.Join(',', fileRoute.UpstreamHttpMethod)}|{string.Join(',', fileRoute.DownstreamHostAndPorts.Select(x => $"{x.Host}:{x.Port}"))}"; + var isStickySession = fileRoute.LoadBalancerOptions is + { + Type: nameof(CookieStickySessions), + Key.Length: > 0 + }; + + if (isStickySession) + { + return $"{nameof(CookieStickySessions)}:{fileRoute.LoadBalancerOptions.Key}"; + } + + var upstreamHttpMethods = Csv(fileRoute.UpstreamHttpMethod); + var downstreamHostAndPorts = Csv(fileRoute.DownstreamHostAndPorts.Select(downstream => $"{downstream.Host}:{downstream.Port}")); + + var keyBuilder = new StringBuilder() + + // UpstreamHttpMethod and UpstreamPathTemplate are required + .AppendNext(upstreamHttpMethods) + .AppendNext(fileRoute.UpstreamPathTemplate) + + // Other properties are optional, replace undefined values with defaults to aid debugging + .AppendNext(Coalesce(fileRoute.UpstreamHost, "no-host")) + + .AppendNext(Coalesce(downstreamHostAndPorts, "no-host-and-port")) + .AppendNext(Coalesce(fileRoute.ServiceNamespace, "no-svc-ns")) + .AppendNext(Coalesce(fileRoute.ServiceName, "no-svc-name")) + .AppendNext(Coalesce(fileRoute.LoadBalancerOptions.Type, "no-lb-type")) + .AppendNext(Coalesce(fileRoute.LoadBalancerOptions.Key, "no-lb-key")); - private static bool IsStickySession(FileRoute fileRoute) => - !string.IsNullOrEmpty(fileRoute.LoadBalancerOptions.Type) - && !string.IsNullOrEmpty(fileRoute.LoadBalancerOptions.Key) - && fileRoute.LoadBalancerOptions.Type == nameof(CookieStickySessions); - } + return keyBuilder.ToString(); + } + + /// + /// Helper function to convert multiple strings into a comma-separated string. + /// + /// The collection of strings to join by comma separator. + /// A in the comma-separated format. + private static string Csv(IEnumerable values) => string.Join(',', values); + + /// + /// Helper function to return the first non-null-or-whitespace string. + /// + /// The 1st string to check. + /// The 2nd string to check. + /// A which is not empty. + private static string Coalesce(string first, string second) => string.IsNullOrWhiteSpace(first) ? second : first; +} + +internal static class RouteKeyCreatorHelpers +{ + /// + /// Helper function to append a string to the key builder, separated by a pipe. + /// + /// The builder of the key. + /// The next word to add. + /// The reference to the builder. + public static StringBuilder AppendNext(this StringBuilder builder, string next) + { + if (builder.Length > 0) + { + builder.Append('|'); + } + + return builder.Append(next); + } } diff --git a/src/Ocelot/Errors/OcelotErrorCode.cs b/src/Ocelot/Errors/OcelotErrorCode.cs index 9063e7142..f74cdace1 100644 --- a/src/Ocelot/Errors/OcelotErrorCode.cs +++ b/src/Ocelot/Errors/OcelotErrorCode.cs @@ -43,5 +43,6 @@ public enum OcelotErrorCode ConnectionToDownstreamServiceError = 38, CouldNotFindLoadBalancerCreator = 39, ErrorInvokingLoadBalancerCreator = 40, + PayloadTooLargeError = 41, } } diff --git a/src/Ocelot/LoadBalancer/LoadBalancers/LoadBalancerHouse.cs b/src/Ocelot/LoadBalancer/LoadBalancers/LoadBalancerHouse.cs index 18a380a48..4a3dc798f 100644 --- a/src/Ocelot/LoadBalancer/LoadBalancers/LoadBalancerHouse.cs +++ b/src/Ocelot/LoadBalancer/LoadBalancers/LoadBalancerHouse.cs @@ -1,5 +1,5 @@ using Ocelot.Configuration; -using Ocelot.Responses; +using Ocelot.Responses; namespace Ocelot.LoadBalancer.LoadBalancers { @@ -18,45 +18,40 @@ public Response Get(DownstreamRoute route, ServiceProviderConfigu { try { - Response result; - if (_loadBalancers.TryGetValue(route.LoadBalancerKey, out var loadBalancer)) - { - loadBalancer = _loadBalancers[route.LoadBalancerKey]; - + { + // TODO Fix ugly reflection issue of dymanic detection in favor of static type property if (route.LoadBalancerOptions.Type != loadBalancer.GetType().Name) - { - result = _factory.Get(route, config); - if (result.IsError) - { - return new ErrorResponse(result.Errors); - } - - loadBalancer = result.Data; - AddLoadBalancer(route.LoadBalancerKey, loadBalancer); + { + return GetResponse(route, config); } return new OkResponse(loadBalancer); } - result = _factory.Get(route, config); - - if (result.IsError) - { - return new ErrorResponse(result.Errors); - } - - loadBalancer = result.Data; - AddLoadBalancer(route.LoadBalancerKey, loadBalancer); - return new OkResponse(loadBalancer); + return GetResponse(route, config); } catch (Exception ex) { - return new ErrorResponse(new List - { - new UnableToFindLoadBalancerError($"unabe to find load balancer for {route.LoadBalancerKey} exception is {ex}"), - }); + return new ErrorResponse( + [ + new UnableToFindLoadBalancerError($"Unable to find load balancer for '{route.LoadBalancerKey}'. Exception: {ex};"), + ]); } + } + + private Response GetResponse(DownstreamRoute route, ServiceProviderConfiguration config) + { + var result = _factory.Get(route, config); + + if (result.IsError) + { + return new ErrorResponse(result.Errors); + } + + var loadBalancer = result.Data; + AddLoadBalancer(route.LoadBalancerKey, loadBalancer); + return new OkResponse(loadBalancer); } private void AddLoadBalancer(string key, ILoadBalancer loadBalancer) diff --git a/src/Ocelot/Middleware/HttpItemsExtensions.cs b/src/Ocelot/Middleware/HttpItemsExtensions.cs index 5d2ada0a5..d8d82ef9d 100644 --- a/src/Ocelot/Middleware/HttpItemsExtensions.cs +++ b/src/Ocelot/Middleware/HttpItemsExtensions.cs @@ -56,7 +56,7 @@ public static IInternalConfiguration IInternalConfiguration(this IDictionary Errors(this IDictionary input) { var errors = input.Get>("Errors"); - return errors ?? new List(); + return errors ?? []; } public static DownstreamRouteFinder.DownstreamRouteHolder diff --git a/src/Ocelot/Multiplexer/InMemoryResponseAggregatorFactory.cs b/src/Ocelot/Multiplexer/InMemoryResponseAggregatorFactory.cs index 8983caea9..ae838e6d0 100644 --- a/src/Ocelot/Multiplexer/InMemoryResponseAggregatorFactory.cs +++ b/src/Ocelot/Multiplexer/InMemoryResponseAggregatorFactory.cs @@ -14,13 +14,6 @@ public InMemoryResponseAggregatorFactory(IDefinedAggregatorProvider provider, IR } public IResponseAggregator Get(Route route) - { - if (!string.IsNullOrEmpty(route.Aggregator)) - { - return _userDefined; - } - - return _simple; - } + => !string.IsNullOrEmpty(route.Aggregator) ? _userDefined : _simple; } } diff --git a/src/Ocelot/Multiplexer/MultiplexingMiddleware.cs b/src/Ocelot/Multiplexer/MultiplexingMiddleware.cs index b6fb52a37..731909006 100644 --- a/src/Ocelot/Multiplexer/MultiplexingMiddleware.cs +++ b/src/Ocelot/Multiplexer/MultiplexingMiddleware.cs @@ -1,219 +1,259 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json.Linq; using Ocelot.Configuration; +using Ocelot.Configuration.File; using Ocelot.DownstreamRouteFinder.UrlMatcher; using Ocelot.Logging; using Ocelot.Middleware; +using System.Collections; +using Route = Ocelot.Configuration.Route; -namespace Ocelot.Multiplexer +namespace Ocelot.Multiplexer; + +public class MultiplexingMiddleware : OcelotMiddleware { - public class MultiplexingMiddleware : OcelotMiddleware + private readonly RequestDelegate _next; + private readonly IResponseAggregatorFactory _factory; + private const string RequestIdString = "RequestId"; + + public MultiplexingMiddleware(RequestDelegate next, + IOcelotLoggerFactory loggerFactory, + IResponseAggregatorFactory factory + ) + : base(loggerFactory.CreateLogger()) { - private readonly RequestDelegate _next; - private readonly IResponseAggregatorFactory _factory; - - public MultiplexingMiddleware(RequestDelegate next, - IOcelotLoggerFactory loggerFactory, - IResponseAggregatorFactory factory - ) - : base(loggerFactory.CreateLogger()) + _factory = factory; + _next = next; + } + + public async Task Invoke(HttpContext httpContext) + { + var downstreamRouteHolder = httpContext.Items.DownstreamRouteHolder(); + var route = downstreamRouteHolder.Route; + var downstreamRoutes = route.DownstreamRoute; + + // Case 1: if websocket request or single downstream route + if (ShouldProcessSingleRoute(httpContext, downstreamRoutes)) { - _factory = factory; - _next = next; + await ProcessSingleRouteAsync(httpContext, downstreamRoutes[0]); + return; } - public async Task Invoke(HttpContext httpContext) + // Case 2: if no downstream routes + if (downstreamRoutes.Count == 0) { - if (httpContext.WebSockets.IsWebSocketRequest) - { - //todo this is obviously stupid - httpContext.Items.UpsertDownstreamRoute(httpContext.Items.DownstreamRouteHolder().Route.DownstreamRoute[0]); - await _next.Invoke(httpContext); - return; - } + return; + } - var routeKeysConfigs = httpContext.Items.DownstreamRouteHolder().Route.DownstreamRouteConfig; - if (routeKeysConfigs == null || !routeKeysConfigs.Any()) - { - var downstreamRouteHolder = httpContext.Items.DownstreamRouteHolder(); + // Case 3: if multiple downstream routes + var routeKeysConfigs = route.DownstreamRouteConfig; + if (routeKeysConfigs == null || routeKeysConfigs.Count == 0) + { + await ProcessRoutesAsync(httpContext, route); + return; + } - var tasks = new Task[downstreamRouteHolder.Route.DownstreamRoute.Count]; + // Case 4: if multiple downstream routes with route keys + var mainResponseContext = await ProcessMainRouteAsync(httpContext, downstreamRoutes[0]); + if (mainResponseContext == null) + { + return; + } - for (var i = 0; i < downstreamRouteHolder.Route.DownstreamRoute.Count; i++) - { - var newHttpContext = Copy(httpContext); + var responsesContexts = await ProcessRoutesWithRouteKeysAsync(httpContext, downstreamRoutes, routeKeysConfigs, mainResponseContext); + if (responsesContexts.Length == 0) + { + return; + } - newHttpContext.Items - .Add("RequestId", httpContext.Items["RequestId"]); - newHttpContext.Items - .SetIInternalConfiguration(httpContext.Items.IInternalConfiguration()); - newHttpContext.Items - .UpsertTemplatePlaceholderNameAndValues(httpContext.Items.TemplatePlaceholderNameAndValues()); - newHttpContext.Items - .UpsertDownstreamRoute(downstreamRouteHolder.Route.DownstreamRoute[i]); + await MapResponsesAsync(httpContext, route, mainResponseContext, responsesContexts); + } - tasks[i] = Fire(newHttpContext, _next); - } + /// + /// Helper method to determine if only the first downstream route should be processed. + /// It is the case if the request is a websocket request or if there is only one downstream route. + /// + /// The http context. + /// The downstream routes. + /// True if only the first downstream route should be processed. + private static bool ShouldProcessSingleRoute(HttpContext context, ICollection routes) + => context.WebSockets.IsWebSocketRequest || routes.Count == 1; + + /// + /// Processing a single downstream route (no route keys). + /// In that case, no need to make copies of the http context. + /// + /// The http context. + /// The downstream route. + /// A representing the asynchronous operation. + protected virtual Task ProcessSingleRouteAsync(HttpContext context, DownstreamRoute route) + { + context.Items.UpsertDownstreamRoute(route); + return _next.Invoke(context); + } - await Task.WhenAll(tasks); + /// + /// Processing the downstream routes (no route keys). + /// + /// The main http context. + /// The route. + private async Task ProcessRoutesAsync(HttpContext context, Route route) + { + var tasks = route.DownstreamRoute + .Select(downstreamRoute => ProcessRouteAsync(context, downstreamRoute)) + .ToArray(); + var contexts = await Task.WhenAll(tasks); + await MapAsync(context, route, [.. contexts]); + } - var contexts = new List(); + /// + /// When using route keys, the first route is the main route and the rest are additional routes. + /// Since we need to break if the main route response is null, we must process the main route first. + /// + /// The http context. + /// The first route, the main route. + /// The updated http context. + private async Task ProcessMainRouteAsync(HttpContext context, DownstreamRoute route) + { + context.Items.UpsertDownstreamRoute(route); + await _next.Invoke(context); + return context; + } - foreach (var task in tasks) - { - var finished = await task; - contexts.Add(finished); - } + /// + /// Processing the downstream routes with route keys except the main route that has already been processed. + /// + /// The main http context. + /// The downstream routes. + /// The route keys config. + /// The response from the main route. + /// A list of the tasks' http contexts. + protected virtual async Task ProcessRoutesWithRouteKeysAsync(HttpContext context, IEnumerable routes, IReadOnlyCollection routeKeysConfigs, HttpContext mainResponse) + { + var processing = new List>(); + var content = await mainResponse.Items.DownstreamResponse().Content.ReadAsStringAsync(); + var jObject = JToken.Parse(content); - await Map(httpContext, downstreamRouteHolder.Route, contexts); - } - else + foreach (var downstreamRoute in routes.Skip(1)) + { + var matchAdvancedAgg = routeKeysConfigs.FirstOrDefault(q => q.RouteKey == downstreamRoute.Key); + if (matchAdvancedAgg != null) { - httpContext.Items.UpsertDownstreamRoute(httpContext.Items.DownstreamRouteHolder().Route.DownstreamRoute[0]); - var mainResponse = await Fire(httpContext, _next); - - if (httpContext.Items.DownstreamRouteHolder().Route.DownstreamRoute.Count == 1) - { - MapNotAggregate(httpContext, new List { mainResponse }); - return; - } - - var tasks = new List>(); - - if (mainResponse.Items.DownstreamResponse() == null) - { - return; - } - - var content = await mainResponse.Items.DownstreamResponse().Content.ReadAsStringAsync(); - - var jObject = Newtonsoft.Json.Linq.JToken.Parse(content); - - for (var i = 1; i < httpContext.Items.DownstreamRouteHolder().Route.DownstreamRoute.Count; i++) - { - var templatePlaceholderNameAndValues = httpContext.Items.TemplatePlaceholderNameAndValues(); - - var downstreamRoute = httpContext.Items.DownstreamRouteHolder().Route.DownstreamRoute[i]; - - var matchAdvancedAgg = routeKeysConfigs - .FirstOrDefault(q => q.RouteKey == downstreamRoute.Key); - - if (matchAdvancedAgg != null) - { - var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct(); - - foreach (var value in values) - { - var newHttpContext = Copy(httpContext); - - var tPnv = httpContext.Items.TemplatePlaceholderNameAndValues(); - tPnv.Add(new PlaceholderNameAndValue('{' + matchAdvancedAgg.Parameter + '}', value)); - - newHttpContext.Items - .Add("RequestId", httpContext.Items["RequestId"]); - - newHttpContext.Items - .SetIInternalConfiguration(httpContext.Items.IInternalConfiguration()); - - newHttpContext.Items - .UpsertTemplatePlaceholderNameAndValues(tPnv); - - newHttpContext.Items - .UpsertDownstreamRoute(downstreamRoute); - - tasks.Add(Fire(newHttpContext, _next)); - } - } - else - { - var newHttpContext = Copy(httpContext); - - newHttpContext.Items - .Add("RequestId", httpContext.Items["RequestId"]); - - newHttpContext.Items - .SetIInternalConfiguration(httpContext.Items.IInternalConfiguration()); - - newHttpContext.Items - .UpsertTemplatePlaceholderNameAndValues(templatePlaceholderNameAndValues); - - newHttpContext.Items - .UpsertDownstreamRoute(downstreamRoute); - - tasks.Add(Fire(newHttpContext, _next)); - } - } + processing.AddRange(ProcessRouteWithComplexAggregation(matchAdvancedAgg, jObject, context, downstreamRoute)); + continue; + } - await Task.WhenAll(tasks); + processing.Add(ProcessRouteAsync(context, downstreamRoute)); + } - var contexts = new List { mainResponse }; + return await Task.WhenAll(processing); + } - foreach (var task in tasks) - { - var finished = await task; - contexts.Add(finished); - } + /// + /// Mapping responses. + /// + private Task MapResponsesAsync(HttpContext context, Route route, HttpContext mainResponseContext, IEnumerable responsesContexts) + { + var contexts = new List { mainResponseContext }; + contexts.AddRange(responsesContexts); + return MapAsync(context, route, contexts); + } - await Map(httpContext, httpContext.Items.DownstreamRouteHolder().Route, contexts); - } + /// + /// Processing a route with aggregation. + /// + private IEnumerable> ProcessRouteWithComplexAggregation(AggregateRouteConfig matchAdvancedAgg, + JToken jObject, HttpContext httpContext, DownstreamRoute downstreamRoute) + { + var processing = new List>(); + var values = jObject.SelectTokens(matchAdvancedAgg.JsonPath).Select(s => s.ToString()).Distinct(); + foreach (var value in values) + { + var tPnv = httpContext.Items.TemplatePlaceholderNameAndValues(); + tPnv.Add(new PlaceholderNameAndValue('{' + matchAdvancedAgg.Parameter + '}', value)); + processing.Add(ProcessRouteAsync(httpContext, downstreamRoute, tPnv)); } - private static HttpContext Copy(HttpContext source) - { - var target = new DefaultHttpContext(); + return processing; + } - foreach (var header in source.Request.Headers) - { - target.Request.Headers.TryAdd(header.Key, header.Value); - } + /// + /// Process a downstream route asynchronously. + /// + /// The cloned Http context. + private async Task ProcessRouteAsync(HttpContext sourceContext, DownstreamRoute route, List placeholders = null) + { + var newHttpContext = CreateThreadContext(sourceContext); + CopyItemsToNewContext(newHttpContext, sourceContext, placeholders); + newHttpContext.Items.UpsertDownstreamRoute(route); - target.Request.Body = source.Request.Body; - target.Request.ContentLength = source.Request.ContentLength; - target.Request.ContentType = source.Request.ContentType; - target.Request.Host = source.Request.Host; - target.Request.Method = source.Request.Method; - target.Request.Path = source.Request.Path; - target.Request.PathBase = source.Request.PathBase; - target.Request.Protocol = source.Request.Protocol; - target.Request.Query = source.Request.Query; - target.Request.QueryString = source.Request.QueryString; - target.Request.Scheme = source.Request.Scheme; - target.Request.IsHttps = source.Request.IsHttps; - target.Request.RouteValues = source.Request.RouteValues; - target.Connection.RemoteIpAddress = source.Connection.RemoteIpAddress; - target.RequestServices = source.RequestServices; - target.RequestAborted = source.RequestAborted; - return target; - } + await _next.Invoke(newHttpContext); + return newHttpContext; + } + + /// + /// Copying some needed parameters to the Http context items. + /// + private static void CopyItemsToNewContext(HttpContext target, HttpContext source, List placeholders = null) + { + target.Items.Add(RequestIdString, source.Items[RequestIdString]); + target.Items.SetIInternalConfiguration(source.Items.IInternalConfiguration()); + target.Items.UpsertTemplatePlaceholderNameAndValues(placeholders ?? + source.Items.TemplatePlaceholderNameAndValues()); + } - private async Task Map(HttpContext httpContext, Route route, List contexts) + /// + /// Creates a new HttpContext based on the source. + /// + /// The base http context. + /// The cloned context. + private static HttpContext CreateThreadContext(HttpContext source) + { + var from = source.Request; + var target = new DefaultHttpContext { - if (route.DownstreamRoute.Count > 1) + Request = { - var aggregator = _factory.Get(route); - await aggregator.Aggregate(route, httpContext, contexts); - } - else + Body = from.Body, // TODO Consider stream cloning for multiple reads + ContentLength = from.ContentLength, + ContentType = from.ContentType, + Host = from.Host, + Method = from.Method, + Path = from.Path, + PathBase = from.PathBase, + Protocol = from.Protocol, + QueryString = from.QueryString, + Scheme = from.Scheme, + IsHttps = from.IsHttps, + Query = new QueryCollection(new Dictionary(from.Query)), + RouteValues = new(from.RouteValues), + }, + Connection = { - MapNotAggregate(httpContext, contexts); - } - } - - private static void MapNotAggregate(HttpContext httpContext, List downstreamContexts) + RemoteIpAddress = source.Connection.RemoteIpAddress, + }, + RequestServices = source.RequestServices, + RequestAborted = source.RequestAborted, + User = source.User, + }; + + foreach (var header in from.Headers) { - //assume at least one..if this errors then it will be caught by global exception handler - var finished = downstreamContexts.First(); - - httpContext.Items.UpsertErrors(finished.Items.Errors()); - - httpContext.Items.UpsertDownstreamRequest(finished.Items.DownstreamRequest()); - - httpContext.Items.UpsertDownstreamResponse(finished.Items.DownstreamResponse()); + target.Request.Headers[header.Key] = header.Value.ToArray(); } - private static async Task Fire(HttpContext httpContext, RequestDelegate next) + return target; + } + + protected virtual Task MapAsync(HttpContext httpContext, Route route, List contexts) + { + if (route.DownstreamRoute.Count == 1) { - await next.Invoke(httpContext); - return httpContext; + return Task.CompletedTask; } + + var aggregator = _factory.Get(route); + return aggregator.Aggregate(route, httpContext, contexts); } } diff --git a/src/Ocelot/Multiplexer/ServiceLocatorDefinedAggregatorProvider.cs b/src/Ocelot/Multiplexer/ServiceLocatorDefinedAggregatorProvider.cs index bbb2457ad..d0841eeba 100644 --- a/src/Ocelot/Multiplexer/ServiceLocatorDefinedAggregatorProvider.cs +++ b/src/Ocelot/Multiplexer/ServiceLocatorDefinedAggregatorProvider.cs @@ -15,9 +15,9 @@ public ServiceLocatorDefinedAggregatorProvider(IServiceProvider services) public Response Get(Route route) { - if (_aggregators.ContainsKey(route.Aggregator)) + if (_aggregators.TryGetValue(route.Aggregator, out var aggregator)) { - return new OkResponse(_aggregators[route.Aggregator]); + return new OkResponse(aggregator); } return new ErrorResponse(new CouldNotFindAggregatorError(route.Aggregator)); diff --git a/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs b/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHandler.cs similarity index 86% rename from src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs rename to src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHandler.cs index e286e3161..c98e256ad 100644 --- a/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHanlder.cs +++ b/src/Ocelot/RateLimit/DistributedCacheRateLimitCounterHandler.cs @@ -1,42 +1,42 @@ using Microsoft.Extensions.Caching.Distributed; using Newtonsoft.Json; - -namespace Ocelot.RateLimit -{ - public class DistributedCacheRateLimitCounterHanlder : IRateLimitCounterHandler - { - private readonly IDistributedCache _memoryCache; - - public DistributedCacheRateLimitCounterHanlder(IDistributedCache memoryCache) - { - _memoryCache = memoryCache; - } - - public void Set(string id, RateLimitCounter counter, TimeSpan expirationTime) - { - _memoryCache.SetString(id, JsonConvert.SerializeObject(counter), new DistributedCacheEntryOptions().SetAbsoluteExpiration(expirationTime)); - } - - public bool Exists(string id) - { - var stored = _memoryCache.GetString(id); - return !string.IsNullOrEmpty(stored); - } - - public RateLimitCounter? Get(string id) - { - var stored = _memoryCache.GetString(id); - if (!string.IsNullOrEmpty(stored)) - { - return JsonConvert.DeserializeObject(stored); - } - - return null; - } - - public void Remove(string id) - { - _memoryCache.Remove(id); - } - } + +namespace Ocelot.RateLimit +{ + public class DistributedCacheRateLimitCounterHandler : IRateLimitCounterHandler + { + private readonly IDistributedCache _memoryCache; + + public DistributedCacheRateLimitCounterHandler(IDistributedCache memoryCache) + { + _memoryCache = memoryCache; + } + + public void Set(string id, RateLimitCounter counter, TimeSpan expirationTime) + { + _memoryCache.SetString(id, JsonConvert.SerializeObject(counter), new DistributedCacheEntryOptions().SetAbsoluteExpiration(expirationTime)); + } + + public bool Exists(string id) + { + var stored = _memoryCache.GetString(id); + return !string.IsNullOrEmpty(stored); + } + + public RateLimitCounter? Get(string id) + { + var stored = _memoryCache.GetString(id); + if (!string.IsNullOrEmpty(stored)) + { + return JsonConvert.DeserializeObject(stored); + } + + return null; + } + + public void Remove(string id) + { + _memoryCache.Remove(id); + } + } } diff --git a/src/Ocelot/Request/Mapper/PayloadTooLargeError.cs b/src/Ocelot/Request/Mapper/PayloadTooLargeError.cs new file mode 100644 index 000000000..ba7081982 --- /dev/null +++ b/src/Ocelot/Request/Mapper/PayloadTooLargeError.cs @@ -0,0 +1,10 @@ +using Ocelot.Errors; + +namespace Ocelot.Request.Mapper; + +public class PayloadTooLargeError : Error +{ + public PayloadTooLargeError(Exception exception) : base(exception.Message, OcelotErrorCode.PayloadTooLargeError, (int) System.Net.HttpStatusCode.RequestEntityTooLarge) + { + } +} diff --git a/src/Ocelot/Request/Mapper/RequestMapper.cs b/src/Ocelot/Request/Mapper/RequestMapper.cs index 8bdde02bb..883c42350 100644 --- a/src/Ocelot/Request/Mapper/RequestMapper.cs +++ b/src/Ocelot/Request/Mapper/RequestMapper.cs @@ -1,87 +1,91 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.Primitives; -using Ocelot.Configuration; - -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); +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Primitives; +using Ocelot.Configuration; + +namespace Ocelot.Request.Mapper; + +public class RequestMapper : IRequestMapper +{ + private static readonly HashSet UnsupportedHeaders = new(StringComparer.OrdinalIgnoreCase) { "host", "transfer-encoding" }; + 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) + { + HttpContent content; + + // No content if we have no body or if the request has no content according to RFC 2616 section 4.3 + if (request.Body == null + || (!request.ContentLength.HasValue && StringValues.IsNullOrEmpty(request.Headers.TransferEncoding))) + { + return null; + } + + content = request.ContentLength is 0 + ? new ByteArrayContent(Array.Empty()) + : 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 index 0e8294db7..b18e1040e 100644 --- a/src/Ocelot/Request/Mapper/StreamHttpContent.cs +++ b/src/Ocelot/Request/Mapper/StreamHttpContent.cs @@ -8,25 +8,24 @@ public class StreamHttpContent : HttpContent private const int DefaultBufferSize = 65536; public const long UnknownLength = -1; private readonly HttpContext _context; + private readonly long _contentLength; public StreamHttpContent(HttpContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); + _contentLength = context.Request.ContentLength ?? UnknownLength; } - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context, - CancellationToken cancellationToken) - => await CopyAsync(_context.Request.Body, stream, Headers.ContentLength ?? UnknownLength, false, - cancellationToken); + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context, CancellationToken cancellationToken) + => CopyAsync(_context.Request.Body, stream, _contentLength, 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 Task SerializeToStreamAsync(Stream stream, TransportContext context) + => CopyAsync(_context.Request.Body, stream, _contentLength, false, CancellationToken.None); protected override bool TryComputeLength(out long length) { - length = -1; - return false; + length = _contentLength; + return length >= 0; } // This is used internally by HttpContent.ReadAsStreamAsync(...) diff --git a/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs b/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs index dad0e856c..5c54d39a4 100644 --- a/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs +++ b/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs @@ -1,6 +1,8 @@ +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Ocelot.Errors; using Ocelot.Errors.QoS; +using Ocelot.Request.Mapper; namespace Ocelot.Requester { @@ -9,6 +11,9 @@ public class HttpExceptionToErrorMapper : IExceptionToErrorMapper /// This is a dictionary of custom mappers for exceptions. private readonly Dictionary> _mappers; + /// 413 status. + private const int RequestEntityTooLarge = (int)HttpStatusCode.RequestEntityTooLarge; + public HttpExceptionToErrorMapper(IServiceProvider serviceProvider) { _mappers = serviceProvider.GetService>>(); @@ -39,6 +44,13 @@ public Error Map(Exception exception) if (type == typeof(HttpRequestException) || type == typeof(TimeoutException)) { + // Inner exception is a BadHttpRequestException, and only this exception exposes the StatusCode property. + // We check if the inner exception is a BadHttpRequestException and if the StatusCode is 413, we return a PayloadTooLargeError + if (exception.InnerException is BadHttpRequestException { StatusCode: RequestEntityTooLarge }) + { + return new PayloadTooLargeError(exception); + } + return new ConnectionToDownstreamServiceError(exception); } diff --git a/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs b/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs index e26d6a5b3..2855437f0 100644 --- a/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs +++ b/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs @@ -39,15 +39,19 @@ public async Task Invoke(HttpContext httpContext) private void CreateLogBasedOnResponse(Response response) { - if (response.Data?.StatusCode <= HttpStatusCode.BadRequest) + var status = response.Data?.StatusCode ?? HttpStatusCode.Processing; + var reason = response.Data?.ReasonPhrase ?? "unknown"; + var uri = response.Data?.RequestMessage?.RequestUri?.ToString() ?? string.Empty; + + string message() => $"{(int)status} ({reason}) status code of request URI: {uri}."; + + if (status < HttpStatusCode.BadRequest) { - Logger.LogInformation(() => - $"{(int)response.Data.StatusCode} ({response.Data.ReasonPhrase}) status code, request uri: {response.Data.RequestMessage?.RequestUri}"); + Logger.LogInformation(message); } - else if (response.Data?.StatusCode >= HttpStatusCode.BadRequest) + else { - Logger.LogWarning( - () => $"{(int)response.Data.StatusCode} ({response.Data.ReasonPhrase}) status code, request uri: {response.Data.RequestMessage?.RequestUri}"); + Logger.LogWarning(message); } } } diff --git a/src/Ocelot/Responder/ErrorsToHttpStatusCodeMapper.cs b/src/Ocelot/Responder/ErrorsToHttpStatusCodeMapper.cs index 2d0e9e8c2..b633e6d9b 100644 --- a/src/Ocelot/Responder/ErrorsToHttpStatusCodeMapper.cs +++ b/src/Ocelot/Responder/ErrorsToHttpStatusCodeMapper.cs @@ -55,6 +55,11 @@ public int Map(List errors) return 500; } + if (errors.Any(e => e.Code == OcelotErrorCode.PayloadTooLargeError)) + { + return 413; + } + return 404; } } diff --git a/test/Ocelot.AcceptanceTests/AggregateTests.cs b/test/Ocelot.AcceptanceTests/AggregateTests.cs index 7cb4c03e3..2028991b4 100644 --- a/test/Ocelot.AcceptanceTests/AggregateTests.cs +++ b/test/Ocelot.AcceptanceTests/AggregateTests.cs @@ -1,117 +1,137 @@ +using IdentityServer4.AccessTokenValidation; +using IdentityServer4.Extensions; +using IdentityServer4.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Ocelot.AcceptanceTests.Authentication; using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; using Ocelot.Middleware; using Ocelot.Multiplexer; namespace Ocelot.AcceptanceTests { - public class AggregateTests : IDisposable + public sealed class AggregateTests : Steps, IDisposable { - private readonly Steps _steps; - private string _downstreamPathOne; - private string _downstreamPathTwo; private readonly ServiceHandler _serviceHandler; + private readonly string[] _downstreamPaths; public AggregateTests() { _serviceHandler = new ServiceHandler(); - _steps = new Steps(); + _downstreamPaths = new string[3]; + } + + public override void Dispose() + { + _serviceHandler.Dispose(); + base.Dispose(); } [Fact] - public void should_fix_issue_597() + [Trait("Issue", "597")] + public void Should_fix_issue_597() { var port = PortFinder.GetRandomPort(); var configuration = new FileConfiguration { - Routes = new List - { - new() + Routes = + [ + new FileRoute { DownstreamPathTemplate = "/api/values?MailId={userid}", UpstreamPathTemplate = "/key1data/{userid}", - UpstreamHttpMethod = new List {"Get"}, + UpstreamHttpMethod = ["Get"], DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() + DownstreamHostAndPorts = + [ + new FileHostAndPort { Host = "localhost", Port = port, }, - }, + ], Key = "key1", }, - new() + new FileRoute { DownstreamPathTemplate = "/api/values?MailId={userid}", UpstreamPathTemplate = "/key2data/{userid}", - UpstreamHttpMethod = new List {"Get"}, + UpstreamHttpMethod = ["Get"], DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() + DownstreamHostAndPorts = + [ + new FileHostAndPort { Host = "localhost", Port = port, }, - }, + ], Key = "key2", }, - new() + new FileRoute { DownstreamPathTemplate = "/api/values?MailId={userid}", UpstreamPathTemplate = "/key3data/{userid}", - UpstreamHttpMethod = new List {"Get"}, + UpstreamHttpMethod = ["Get"], DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() + DownstreamHostAndPorts = + [ + new FileHostAndPort { Host = "localhost", Port = port, }, - }, + ], Key = "key3", }, - new() + new FileRoute { DownstreamPathTemplate = "/api/values?MailId={userid}", UpstreamPathTemplate = "/key4data/{userid}", - UpstreamHttpMethod = new List {"Get"}, + UpstreamHttpMethod = ["Get"], DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() + DownstreamHostAndPorts = + [ + new FileHostAndPort { Host = "localhost", Port = port, }, - }, + ], Key = "key4", }, - }, - Aggregates = new List - { - new() + ], + Aggregates = + [ + new FileAggregateRoute { - RouteKeys = new List{ + RouteKeys = + [ "key1", "key2", "key3", - "key4", - }, + "key4" + ], UpstreamPathTemplate = "/EmpDetail/IN/{userid}", }, - new() + new FileAggregateRoute { - RouteKeys = new List{ + RouteKeys = + [ "key1", - "key2", - }, + "key2" + ], UpstreamPathTemplate = "/EmpDetail/US/{userid}", }, - }, + ], GlobalConfiguration = new FileGlobalConfiguration { RequestIdKey = "CorrelationID", @@ -121,92 +141,94 @@ public void should_fix_issue_597() var expected = "{\"key1\":some_data,\"key2\":some_data}"; this.Given(x => x.GivenServiceIsRunning($"http://localhost:{port}", 200, "some_data")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/EmpDetail/US/1")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(expected)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/EmpDetail/US/1")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(expected)) .BDDfy(); } [Fact] - public void should_return_response_200_with_advanced_aggregate_configs() + public void Should_return_response_200_with_advanced_aggregate_configs() { var port1 = PortFinder.GetRandomPort(); var port2 = PortFinder.GetRandomPort(); var port3 = PortFinder.GetRandomPort(); var configuration = new FileConfiguration { - Routes = new List + Routes = + [ + new FileRoute { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port1, - }, - }, - UpstreamPathTemplate = "/Comments", - UpstreamHttpMethod = new List { "Get" }, - Key = "Comments", - }, - new() - { - DownstreamPathTemplate = "/users/{userId}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port2, - }, - }, - UpstreamPathTemplate = "/UserDetails", - UpstreamHttpMethod = new List { "Get" }, - Key = "UserDetails", - }, - new() - { - DownstreamPathTemplate = "/posts/{postId}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - new() - { - Host = "localhost", - Port = port3, - }, + Host = "localhost", + Port = port1, }, - UpstreamPathTemplate = "/PostDetails", - UpstreamHttpMethod = new List { "Get" }, - Key = "PostDetails", - }, + ], + UpstreamPathTemplate = "/Comments", + UpstreamHttpMethod = ["Get"], + Key = "Comments", }, - Aggregates = new List + new FileRoute { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List + DownstreamPathTemplate = "/users/{userId}", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - "Comments", - "UserDetails", - "PostDetails", + Host = "localhost", + Port = port2, }, - RouteKeysConfig = new List() + ], + UpstreamPathTemplate = "/UserDetails", + UpstreamHttpMethod = ["Get"], + Key = "UserDetails", + }, + new FileRoute + { + DownstreamPathTemplate = "/posts/{postId}", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - new(){RouteKey = "UserDetails",JsonPath = "$[*].writerId",Parameter = "userId"}, - new(){RouteKey = "PostDetails",JsonPath = "$[*].postId",Parameter = "postId"}, + Host = "localhost", + Port = port3, }, - }, + ], + UpstreamPathTemplate = "/PostDetails", + UpstreamHttpMethod = ["Get"], + Key = "PostDetails", + }, + ], + Aggregates = + [ + new FileAggregateRoute + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Comments", + "UserDetails", + "PostDetails" + ], + RouteKeysConfig = + [ + new AggregateRouteConfig + { RouteKey = "UserDetails", JsonPath = "$[*].writerId", Parameter = "userId" }, + new AggregateRouteConfig + { RouteKey = "PostDetails", JsonPath = "$[*].postId", Parameter = "postId" }, + ], }, + ], }; var userDetailsResponseContent = @"{""id"":1,""firstName"":""abolfazl"",""lastName"":""rajabpour""}"; @@ -215,362 +237,396 @@ public void should_return_response_200_with_advanced_aggregate_configs() var expected = "{\"Comments\":" + commentsResponseContent + ",\"UserDetails\":" + userDetailsResponseContent + ",\"PostDetails\":" + postDetailsResponseContent + "}"; - this.Given(x => x.GivenServiceOneIsRunning($"http://localhost:{port1}", "/", 200, commentsResponseContent)) - .Given(x => x.GivenServiceTwoIsRunning($"http://localhost:{port2}", "/users/1", 200, userDetailsResponseContent)) - .Given(x => x.GivenServiceTwoIsRunning($"http://localhost:{port3}", "/posts/2", 200, postDetailsResponseContent)) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(expected)) + this.Given(x => x.GivenServiceIsRunning(0, port1, "/", 200, commentsResponseContent)) + .Given(x => x.GivenServiceIsRunning(1, port2, "/users/1", 200, userDetailsResponseContent)) + .Given(x => x.GivenServiceIsRunning(2, port3, "/posts/2", 200, postDetailsResponseContent)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(expected)) .BDDfy(); } [Fact] - public void should_return_response_200_with_simple_url_user_defined_aggregate() + public void Should_return_response_200_with_simple_url_user_defined_aggregate() { var port1 = PortFinder.GetRandomPort(); var port2 = PortFinder.GetRandomPort(); var configuration = new FileConfiguration { - Routes = new List + Routes = + [ + new FileRoute { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port1, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - new() - { - Host = "localhost", - Port = port2, - }, + Host = "localhost", + Port = port1, }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - }, + ], + UpstreamPathTemplate = "/laura", + UpstreamHttpMethod = ["Get"], + Key = "Laura", }, - Aggregates = new List + + new FileRoute { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - "Laura", - "Tom", + Host = "localhost", + Port = port2, }, - Aggregator = "FakeDefinedAggregator", - }, + ], + UpstreamPathTemplate = "/tom", + UpstreamHttpMethod = ["Get"], + Key = "Tom", }, + ], + Aggregates = + [ + new FileAggregateRoute + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Laura", + "Tom" + ], + Aggregator = "FakeDefinedAggregator", + }, + ], }; var expected = "Bye from Laura, Bye from Tom"; - this.Given(x => x.GivenServiceOneIsRunning($"http://localhost:{port1}", "/", 200, "{Hello from Laura}")) - .Given(x => x.GivenServiceTwoIsRunning($"http://localhost:{port2}", "/", 200, "{Hello from Tom}")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunningWithSpecificAggregatorsRegisteredInDi()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(expected)) + this.Given(x => x.GivenServiceIsRunning(0, port1, "/", 200, "{Hello from Laura}")) + .Given(x => x.GivenServiceIsRunning(1, port2, "/", 200, "{Hello from Tom}")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningWithSpecificAggregatorsRegisteredInDi()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(expected)) .And(x => ThenTheDownstreamUrlPathShouldBe("/", "/")) .BDDfy(); } [Fact] - public void should_return_response_200_with_simple_url() + public void Should_return_response_200_with_simple_url() + { + var port1 = PortFinder.GetRandomPort(); + var port2 = PortFinder.GetRandomPort(); + var route1 = GivenRoute(port1, "/laura", "Laura"); + var route2 = GivenRoute(port2, "/tom", "Tom"); + var configuration = GivenConfiguration(route1, route2); + + this.Given(x => x.GivenServiceIsRunning(0, port1, "/", 200, "{Hello from Laura}")) + .Given(x => x.GivenServiceIsRunning(1, port2, "/", 200, "{Hello from Tom}")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("{\"Laura\":{Hello from Laura},\"Tom\":{Hello from Tom}}")) + .And(x => ThenTheDownstreamUrlPathShouldBe("/", "/")) + .BDDfy(); + } + + [Fact] + public void Should_return_response_200_with_simple_url_one_service_404() { var port1 = PortFinder.GetRandomPort(); var port2 = PortFinder.GetRandomPort(); var configuration = new FileConfiguration { - Routes = new List + Routes = + [ + new FileRoute { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port1, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - new() - { - Host = "localhost", - Port = port2, - }, + Host = "localhost", + Port = port1, }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - }, + ], + UpstreamPathTemplate = "/laura", + UpstreamHttpMethod = ["Get"], + Key = "Laura", }, - Aggregates = new List + new FileRoute { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - "Laura", - "Tom", + Host = "localhost", + Port = port2, }, - }, + ], + UpstreamPathTemplate = "/tom", + UpstreamHttpMethod = ["Get"], + Key = "Tom", + }, + ], + Aggregates = + [ + new FileAggregateRoute + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Laura", + "Tom" + ], }, + ], }; - var expected = "{\"Laura\":{Hello from Laura},\"Tom\":{Hello from Tom}}"; + var expected = "{\"Laura\":,\"Tom\":{Hello from Tom}}"; - this.Given(x => x.GivenServiceOneIsRunning($"http://localhost:{port1}", "/", 200, "{Hello from Laura}")) - .Given(x => x.GivenServiceTwoIsRunning($"http://localhost:{port2}", "/", 200, "{Hello from Tom}")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(expected)) + this.Given(x => x.GivenServiceIsRunning(0, port1, "/", 404, "")) + .Given(x => x.GivenServiceIsRunning(1, port2, "/", 200, "{Hello from Tom}")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(expected)) .And(x => ThenTheDownstreamUrlPathShouldBe("/", "/")) .BDDfy(); } [Fact] - public void should_return_response_200_with_simple_url_one_service_404() + public void Should_return_response_200_with_simple_url_both_service_404() { var port1 = PortFinder.GetRandomPort(); var port2 = PortFinder.GetRandomPort(); var configuration = new FileConfiguration { - Routes = new List + Routes = + [ + new FileRoute { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port1, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - new() - { - Host = "localhost", - Port = port2, - }, + Host = "localhost", + Port = port1, }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - }, + ], + UpstreamPathTemplate = "/laura", + UpstreamHttpMethod = ["Get"], + Key = "Laura", }, - Aggregates = new List + new FileRoute { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - "Laura", - "Tom", + Host = "localhost", + Port = port2, }, - }, + ], + UpstreamPathTemplate = "/tom", + UpstreamHttpMethod = ["Get"], + Key = "Tom", }, + ], + Aggregates = + [ + new FileAggregateRoute + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Laura", + "Tom" + ], + }, + ], }; - var expected = "{\"Laura\":,\"Tom\":{Hello from Tom}}"; + var expected = "{\"Laura\":,\"Tom\":}"; - this.Given(x => x.GivenServiceOneIsRunning($"http://localhost:{port1}", "/", 404, "")) - .Given(x => x.GivenServiceTwoIsRunning($"http://localhost:{port2}", "/", 200, "{Hello from Tom}")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(expected)) + this.Given(x => x.GivenServiceIsRunning(0, port1, "/", 404, "")) + .Given(x => x.GivenServiceIsRunning(1, port2, "/", 404, "")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(expected)) .And(x => ThenTheDownstreamUrlPathShouldBe("/", "/")) .BDDfy(); } [Fact] - public void should_return_response_200_with_simple_url_both_service_404() + public void Should_be_thread_safe() { var port1 = PortFinder.GetRandomPort(); var port2 = PortFinder.GetRandomPort(); var configuration = new FileConfiguration { - Routes = new List + Routes = + [ + new FileRoute { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port1, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - new() - { - Host = "localhost", - Port = port2, - }, + Host = "localhost", + Port = port1, }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - }, + ], + UpstreamPathTemplate = "/laura", + UpstreamHttpMethod = ["Get"], + Key = "Laura", }, - Aggregates = new List + new FileRoute { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List + DownstreamPathTemplate = "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = + [ + new FileHostAndPort { - "Laura", - "Tom", + Host = "localhost", + Port = port2, }, - }, + ], + UpstreamPathTemplate = "/tom", + UpstreamHttpMethod = ["Get"], + Key = "Tom", + }, + ], + Aggregates = + [ + new FileAggregateRoute + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = + [ + "Laura", + "Tom" + ], }, + ], }; - var expected = "{\"Laura\":,\"Tom\":}"; - - this.Given(x => x.GivenServiceOneIsRunning($"http://localhost:{port1}", "/", 404, "")) - .Given(x => x.GivenServiceTwoIsRunning($"http://localhost:{port2}", "/", 404, "")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe(expected)) + this.Given(x => x.GivenServiceIsRunning(0, port1, "/", 200, "{Hello from Laura}")) + .Given(x => x.GivenServiceIsRunning(1, port2, "/", 200, "{Hello from Tom}")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIMakeLotsOfDifferentRequestsToTheApiGateway()) .And(x => ThenTheDownstreamUrlPathShouldBe("/", "/")) .BDDfy(); } [Fact] - public void should_be_thread_safe() + [Trait("Bug", "1396")] + public void Should_return_response_200_with_user_forwarding() { var port1 = PortFinder.GetRandomPort(); var port2 = PortFinder.GetRandomPort(); - var configuration = new FileConfiguration + var port3 = PortFinder.GetRandomPort(); + var route1 = GivenRoute(port1, "/laura", "Laura"); + var route2 = GivenRoute(port2, "/tom", "Tom"); + var configuration = GivenConfiguration(route1, route2); + var identityServerUrl = $"{Uri.UriSchemeHttp}://localhost:{port3}"; + Action options = o => { - Routes = new List + o.Authority = identityServerUrl; + o.ApiName = "api"; + o.RequireHttpsMetadata = false; + o.SupportedTokens = SupportedTokens.Both; + o.ApiSecret = "secret"; + o.ForwardDefault = IdentityServerAuthenticationDefaults.AuthenticationScheme; + }; + Action configureServices = s => + { + s.AddOcelot(); + s.AddMvcCore(options => + { + var policy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .RequireClaim("scope", "api") + .Build(); + options.Filters.Add(new AuthorizeFilter(policy)); + }); + s.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme) + .AddIdentityServerAuthentication(options); + }; + var count = 0; + var actualContexts = new HttpContext[2]; + Action configureApp = async (app) => + { + var configuration = new OcelotPipelineConfiguration + { + PreErrorResponderMiddleware = async (context, next) => { - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port1, - }, - }, - UpstreamPathTemplate = "/laura", - UpstreamHttpMethod = new List { "Get" }, - Key = "Laura", - }, - new() - { - DownstreamPathTemplate = "/", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port2, - }, - }, - UpstreamPathTemplate = "/tom", - UpstreamHttpMethod = new List { "Get" }, - Key = "Tom", - }, + var auth = await context.AuthenticateAsync(); + context.User = (auth.Succeeded && auth.Principal?.IsAuthenticated() == true) + ? auth.Principal : null; + await next.Invoke(); }, - Aggregates = new List + AuthorizationMiddleware = (context, next) => { - new() - { - UpstreamPathTemplate = "/", - UpstreamHost = "localhost", - RouteKeys = new List - { - "Laura", - "Tom", - }, - }, + actualContexts[count++] = context; + return next.Invoke(); }, + }; + await app.UseOcelot(configuration); }; - - this.Given(x => x.GivenServiceOneIsRunning($"http://localhost:{port1}", "/", 200, "{Hello from Laura}")) - .Given(x => x.GivenServiceTwoIsRunning($"http://localhost:{port2}", "/", 200, "{Hello from Tom}")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIMakeLotsOfDifferentRequestsToTheApiGateway()) - .And(x => ThenTheDownstreamUrlPathShouldBe("/", "/")) - .BDDfy(); + using (var auth = new AuthenticationTests()) + { + this.Given(x => auth.GivenThereIsAnIdentityServerOn(identityServerUrl, AccessTokenType.Jwt)) + .And(x => x.GivenServiceIsRunning(0, port1, "/", 200, "{Hello from Laura}")) + .And(x => x.GivenServiceIsRunning(1, port2, "/", 200, "{Hello from Tom}")) + .And(x => auth.GivenIHaveAToken(identityServerUrl)) + .And(x => auth.GivenThereIsAConfiguration(configuration)) + .And(x => auth.GivenOcelotIsRunningWithServices(configureServices, configureApp)) + .And(x => auth.GivenIHaveAddedATokenToMyRequest()) + .When(x => auth.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => auth.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => auth.ThenTheResponseBodyShouldBe("{\"Laura\":{Hello from Laura},\"Tom\":{Hello from Tom}}")) + .And(x => x.ThenTheDownstreamUrlPathShouldBe("/", "/")) + .BDDfy(); + } + + // Assert + for (var i = 0; i < actualContexts.Length; i++) + { + var ctx = actualContexts[i].ShouldNotBeNull(); + ctx.Items.DownstreamRoute().Key.ShouldBe(configuration.Routes[i].Key); + var user = ctx.User.ShouldNotBeNull(); + user.IsAuthenticated().ShouldBeTrue(); + user.Claims.Count().ShouldBeGreaterThan(1); + user.Claims.FirstOrDefault(c => c is { Type: "scope", Value: "api" }).ShouldNotBeNull(); + } } private void GivenServiceIsRunning(string baseUrl, int statusCode, string responseBody) @@ -582,16 +638,17 @@ private void GivenServiceIsRunning(string baseUrl, int statusCode, string respon }); } - private void GivenServiceOneIsRunning(string baseUrl, string basePath, int statusCode, string responseBody) + private void GivenServiceIsRunning(int index, int port, string basePath, int statusCode, string responseBody) { + var baseUrl = $"{Uri.UriSchemeHttp}://localhost:{port}"; _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => { - _downstreamPathOne = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + _downstreamPaths[index] = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; - if (_downstreamPathOne != basePath) + if (_downstreamPaths[index] != basePath) { context.Response.StatusCode = statusCode; - await context.Response.WriteAsync("downstream path didnt match base path"); + await context.Response.WriteAsync("downstream path didn't match base path"); } else { @@ -601,49 +658,74 @@ private void GivenServiceOneIsRunning(string baseUrl, string basePath, int statu }); } - private void GivenServiceTwoIsRunning(string baseUrl, string basePath, int statusCode, string responseBody) + private void GivenOcelotIsRunningWithSpecificAggregatorsRegisteredInDi() + where TAggregator : class, IDefinedAggregator + where TDependency : class { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => - { - _downstreamPathTwo = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + _webHostBuilder = new WebHostBuilder(); - if (_downstreamPathTwo != basePath) + _webHostBuilder + .ConfigureAppConfiguration((hostingContext, config) => { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync("downstream path didnt match base path"); - } - else + 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, true, false); + config.AddEnvironmentVariables(); + }) + .ConfigureServices(s => { - context.Response.StatusCode = statusCode; - await context.Response.WriteAsync(responseBody); - } - }); + s.AddSingleton(_webHostBuilder); + s.AddSingleton(); + s.AddOcelot() + .AddSingletonDefinedAggregator(); + }) + .Configure(a => { a.UseOcelot().Wait(); }); + + _ocelotServer = new TestServer(_webHostBuilder); + _ocelotClient = _ocelotServer.CreateClient(); } - internal void ThenTheDownstreamUrlPathShouldBe(string expectedDownstreamPathOne, string expectedDownstreamPath) + private void ThenTheDownstreamUrlPathShouldBe(string expectedDownstreamPathOne, string expectedDownstreamPath) { - _downstreamPathOne.ShouldBe(expectedDownstreamPathOne); - _downstreamPathTwo.ShouldBe(expectedDownstreamPath); + _downstreamPaths[0].ShouldBe(expectedDownstreamPathOne); + _downstreamPaths[1].ShouldBe(expectedDownstreamPath); } - public void Dispose() + private static FileRoute GivenRoute(int port, string upstream, string key) => new() { - _serviceHandler.Dispose(); - _steps.Dispose(); + DownstreamPathTemplate = "/", + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = [new FileHostAndPort("localhost", port)], + UpstreamPathTemplate = upstream, + UpstreamHttpMethod = [HttpMethods.Get], + Key = key, + }; + + private static new FileConfiguration GivenConfiguration(params FileRoute[] routes) + { + var obj = Steps.GivenConfiguration(routes); + obj.Aggregates.Add( + new() + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = routes.Select(r => r.Key).ToList(), // [ "Laura", "Tom" ], + } + ); + return obj; } } - public class FakeDepdendency + public class FakeDep { } public class FakeDefinedAggregator : IDefinedAggregator { - private readonly FakeDepdendency _dep; - - public FakeDefinedAggregator(FakeDepdendency dep) + public FakeDefinedAggregator(FakeDep dep) { - _dep = dep; } public async Task Aggregate(List responses) diff --git a/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs index 111cd3afe..ea3e9ee3a 100644 --- a/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs +++ b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs @@ -112,7 +112,7 @@ public void Should_return_201_using_identity_server_reference_token() .BDDfy(); } - private void GivenThereIsAnIdentityServerOn(string url, AccessTokenType tokenType) + public void GivenThereIsAnIdentityServerOn(string url, AccessTokenType tokenType) { var scopes = new string[] { "api", "api2" }; _identityServerBuilder = CreateIdentityServer(url, tokenType, scopes, null) diff --git a/test/Ocelot.AcceptanceTests/HttpTests.cs b/test/Ocelot.AcceptanceTests/HttpTests.cs index 532bd6276..778dcb155 100644 --- a/test/Ocelot.AcceptanceTests/HttpTests.cs +++ b/test/Ocelot.AcceptanceTests/HttpTests.cs @@ -1,6 +1,8 @@ +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; using Ocelot.Configuration.File; +using System.Security.Authentication; namespace Ocelot.AcceptanceTests { @@ -216,25 +218,45 @@ public void should_return_response_200_when_using_http_two_to_talk_to_server_run } private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int port, HttpProtocols protocols) - { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + { + void options(KestrelServerOptions serverOptions) + { + serverOptions.Listen(IPAddress.Loopback, port, listenOptions => + { + listenOptions.Protocols = protocols; + }); + } + + _serviceHandler.GivenThereIsAServiceRunningOnWithKestrelOptions(baseUrl, basePath, options, async context => { context.Response.StatusCode = 200; var reader = new StreamReader(context.Request.Body); var body = await reader.ReadToEndAsync(); await context.Response.WriteAsync(body); - }, port, protocols); + }); } private void GivenThereIsAServiceUsingHttpsRunningOn(string baseUrl, string basePath, int port, HttpProtocols protocols) { - _serviceHandler.GivenThereIsAServiceRunningOnUsingHttps(baseUrl, basePath, async context => + void options(KestrelServerOptions serverOptions) + { + serverOptions.Listen(IPAddress.Loopback, port, listenOptions => + { + listenOptions.UseHttps("mycert.pfx", "password", options => + { + options.SslProtocols = SslProtocols.Tls12; + }); + listenOptions.Protocols = protocols; + }); + } + + _serviceHandler.GivenThereIsAServiceRunningOnWithKestrelOptions(baseUrl, basePath, options, async context => { context.Response.StatusCode = 200; var reader = new StreamReader(context.Request.Body); var body = await reader.ReadToEndAsync(); await context.Response.WriteAsync(body); - }, port, protocols); + }); } public void Dispose() diff --git a/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj b/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj index 390879fc4..bb1399943 100644 --- a/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj +++ b/test/Ocelot.AcceptanceTests/Ocelot.AcceptanceTests.csproj @@ -69,6 +69,7 @@ + diff --git a/test/Ocelot.AcceptanceTests/Request/RequestMapperTests.cs b/test/Ocelot.AcceptanceTests/Request/RequestMapperTests.cs new file mode 100644 index 000000000..b5c69b006 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Request/RequestMapperTests.cs @@ -0,0 +1,152 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration.File; +using System.Text; + +namespace Ocelot.AcceptanceTests.Request; + +[Trait("PR", "1972")] +public sealed class RequestMapperTests : Steps, IDisposable +{ + private readonly ServiceHandler _serviceHandler; + + public RequestMapperTests() + { + _serviceHandler = new(); + } + + public override void Dispose() + { + _serviceHandler.Dispose(); + base.Dispose(); + } + + [Fact] + public void Should_map_request_without_content() + { + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/", HttpStatusCode.OK)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(";;")) + .BDDfy(); + } + + [Fact] + public void Should_map_request_with_content_length() + { + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/", HttpStatusCode.OK)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIPostUrlOnTheApiGateway("/", new StringContent("This is some content"))) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("20;;This is some content")) + .BDDfy(); + } + + [Fact] + public void Should_map_request_with_empty_content() + { + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/", HttpStatusCode.OK)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIPostUrlOnTheApiGateway("/", new StringContent(""))) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe("0;;")) + .BDDfy(); + } + + [Fact] + public void Should_map_request_with_chunked_content() + { + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/", HttpStatusCode.OK)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIPostUrlOnTheApiGateway("/", new ChunkedContent("This ", "is some content"))) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(";chunked;This is some content")) + .BDDfy(); + } + + [Fact] + public void Should_map_request_with_empty_chunked_content() + { + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/", HttpStatusCode.OK)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIPostUrlOnTheApiGateway("/", new ChunkedContent())) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(";chunked;")) + .BDDfy(); + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, HttpStatusCode status) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + { + var request = context.Request; + var response = context.Response; + response.StatusCode = (int)status; + + await response.WriteAsync(request.ContentLength + ";" + request.Headers.TransferEncoding + ";"); + await request.Body.CopyToAsync(response.Body); + }); + } + + private static FileRoute GivenRoute(int port, string method = null) => new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = + [ + new("localhost", port), + ], + UpstreamPathTemplate = "/", + UpstreamHttpMethod = [method ?? HttpMethods.Get], + }; +} + +internal class ChunkedContent : HttpContent +{ + private readonly string[] _chunks; + + public ChunkedContent(params string[] chunks) + { + _chunks = chunks; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + foreach (var chunk in _chunks) + { + var bytes = Encoding.Default.GetBytes(chunk); + await stream.WriteAsync(bytes, 0, bytes.Length); + } + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } +} diff --git a/test/Ocelot.AcceptanceTests/Request/StreamContentTests.cs b/test/Ocelot.AcceptanceTests/Request/StreamContentTests.cs new file mode 100644 index 000000000..5c20c53e1 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Request/StreamContentTests.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Ocelot.Configuration.File; +using System.Security.Cryptography; + +namespace Ocelot.AcceptanceTests.Request; + +[Trait("PR", "1972")] +public sealed class StreamContentTests : Steps, IDisposable +{ + private readonly ServiceHandler _serviceHandler; + + public StreamContentTests() + { + _serviceHandler = new ServiceHandler(); + } + + public override void Dispose() + { + _serviceHandler.Dispose(); + base.Dispose(); + } + + [Fact] + public void Should_stream_with_content_length() + { + var contentSize = 1024L * 1024L * 1024L; // 1GB + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIPostUrlOnTheApiGateway("/", new StreamTestContent(contentSize, false))) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(contentSize + ";;" + contentSize)) + .BDDfy(); + } + + [Fact] + public void Should_stream_with_chunked_content() + { + var contentSize = 1024L * 1024L * 1024L; // 1GB + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/")) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIPostUrlOnTheApiGateway("/", new StreamTestContent(contentSize, true))) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(";chunked;" + contentSize)) + .BDDfy(); + } + + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath) + { + static void options(KestrelServerOptions o) + { + o.Limits.MaxRequestBodySize = long.MaxValue; + } + + _serviceHandler.GivenThereIsAServiceRunningOnWithKestrelOptions(baseUrl, basePath, options, async context => + { + var request = context.Request; + var response = context.Response; + + long streamLength = 0; + int readBytes; + var buffer = new byte[8192 - 1]; // Not aligned to sender + + do + { + readBytes = await request.Body.ReadAsync(buffer, 0, buffer.Length); + streamLength += readBytes; + } while (readBytes > 0); + + response.StatusCode = 200; + await response.WriteAsync(request.ContentLength + ";" + request.Headers.TransferEncoding + ";" + streamLength); + }); + } + + private static FileRoute GivenRoute(int port, string method = null) => new() + { + DownstreamPathTemplate = "/", + DownstreamScheme = Uri.UriSchemeHttp, + DownstreamHostAndPorts = + [ + new("localhost", port), + ], + UpstreamPathTemplate = "/", + UpstreamHttpMethod = [method ?? HttpMethods.Get], + }; +} + +internal class StreamTestContent(long size, bool sendChunked) : HttpContent +{ + private readonly byte[] _dataBuffer = RandomNumberGenerator.GetBytes(8192); + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + var remaining = size; + while (remaining > 0) + { + var count = (int)Math.Min(remaining, _dataBuffer.Length); + await stream.WriteAsync(_dataBuffer, 0, count); + remaining -= count; + } + } + + protected override bool TryComputeLength(out long length) + { + if (sendChunked) + { + length = -1; + return false; + } + else + { + length = size; + return true; + } + } +} diff --git a/test/Ocelot.AcceptanceTests/Requester/PayloadTooLargeTests.cs b/test/Ocelot.AcceptanceTests/Requester/PayloadTooLargeTests.cs new file mode 100644 index 000000000..35c789143 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Requester/PayloadTooLargeTests.cs @@ -0,0 +1,166 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Ocelot.Configuration.File; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using System.Runtime.InteropServices; +using System.Text; + +namespace Ocelot.AcceptanceTests.Requester; + +public sealed class PayloadTooLargeTests : Steps, IDisposable +{ + private readonly ServiceHandler _serviceHandler; + private IHost _realServer; + + private const string Payload = + "[{\"_id\":\"6540f8ee7beff536c1304e3a\",\"index\":0,\"guid\":\"349307e2-5b1b-4ea9-8e42-d0d26b35059e\",\"isActive\":true,\"balance\":\"$2,458.86\",\"picture\":\"http://placehold.it/32x32\",\"age\":36,\"eyeColor\":\"blue\",\"name\":\"WalshSloan\",\"gender\":\"male\",\"company\":\"ENOMEN\",\"email\":\"walshsloan@enomen.com\",\"phone\":\"+1(818)463-2479\",\"address\":\"863StoneAvenue,Islandia,NewHampshire,7062\",\"about\":\"Exvelitelitutsintlaborisofficialaborisreprehenderittemporsitminim.Exveniamexetesse.Reprehenderitirurealiquipsuntnostrudcillumaliquipsuntvoluptateessenisivoluptatetemporexercitationsint.Laborumexestipsumincididuntvelit.Idnisiproidenttemporelitnonconsequatestnostrudmollit.\\r\\n\",\"registered\":\"2014-11-13T01:53:09-01:00\",\"latitude\":-1.01137,\"longitude\":160.133312,\"tags\":[\"nisi\",\"eu\",\"anim\",\"ipsum\",\"fugiat\",\"excepteur\",\"culpa\"],\"friends\":[{\"id\":0,\"name\":\"MayNoel\"},{\"id\":1,\"name\":\"RichardsDiaz\"},{\"id\":2,\"name\":\"JannieHarvey\"}],\"greeting\":\"Hello,WalshSloan!Youhave6unreadmessages.\",\"favoriteFruit\":\"banana\"},{\"_id\":\"6540f8ee39e04d0ac854b05d\",\"index\":1,\"guid\":\"0f210e11-94a1-45c7-84a4-c2bfcbe0bbfb\",\"isActive\":false,\"balance\":\"$3,371.91\",\"picture\":\"http://placehold.it/32x32\",\"age\":25,\"eyeColor\":\"green\",\"name\":\"FergusonIngram\",\"gender\":\"male\",\"company\":\"DOGSPA\",\"email\":\"fergusoningram@dogspa.com\",\"phone\":\"+1(804)599-2376\",\"address\":\"130RiverStreet,Bellamy,DistrictOfColumbia,9522\",\"about\":\"Duisvoluptatemollitullamcomollitessedolorvelit.Nonpariaturadipisicingsintdoloranimveniammollitdolorlaborumquisnulla.Ametametametnonlaborevoluptate.Eiusmoddocupidatatveniamirureessequiullamcoincididuntea.\\r\\n\",\"registered\":\"2014-11-01T03:51:36-01:00\",\"latitude\":-57.122954,\"longitude\":-91.22665,\"tags\":[\"nostrud\",\"ipsum\",\"id\",\"cupidatat\",\"consectetur\",\"labore\",\"ullamco\"],\"friends\":[{\"id\":0,\"name\":\"TabithaHuffman\"},{\"id\":1,\"name\":\"LydiaStark\"},{\"id\":2,\"name\":\"FaithStuart\"}],\"greeting\":\"Hello,FergusonIngram!Youhave3unreadmessages.\",\"favoriteFruit\":\"banana\"}]"; + + public PayloadTooLargeTests() + { + _serviceHandler = new ServiceHandler(); + } + + /// + /// Disposes the instance. + /// + /// + /// Dispose pattern is implemented in the base class. + /// + public override void Dispose() + { + _serviceHandler.Dispose(); + _realServer?.Dispose(); + base.Dispose(); + } + + [Fact] + public void Should_throw_payload_too_large_exception_using_kestrel() + { + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port))) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningOnKestrelWithCustomBodyMaxSize(1024)) + .When(x => WhenIPostUrlOnTheApiGateway("/", new ByteArrayContent(Encoding.UTF8.GetBytes(Payload)))) + .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.RequestEntityTooLarge)) + .BDDfy(); + } + + [SkippableFact] + public void Should_throw_payload_too_large_exception_using_http_sys() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + + var port = PortFinder.GetRandomPort(); + var route = GivenRoute(port, HttpMethods.Post); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port))) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunningOnHttpSysWithCustomBodyMaxSize(1024)) + .When(x => WhenIPostUrlOnTheApiGateway("/", new ByteArrayContent(Encoding.UTF8.GetBytes(Payload)))) + .Then(x => ThenTheStatusCodeShouldBe((int)HttpStatusCode.RequestEntityTooLarge)) + .BDDfy(); + } + + private static FileRoute GivenRoute(int port, string method = null) => new() + { + DownstreamPathTemplate = "/", + DownstreamHostAndPorts = + [ + new("localhost", port), + ], + DownstreamScheme = Uri.UriSchemeHttp, + UpstreamPathTemplate = "/", + UpstreamHttpMethod = [method ?? HttpMethods.Get], + }; + + private void GivenThereIsAServiceRunningOn(string baseUrl) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, async context => + { + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsync(string.Empty); + }); + } + + private void GivenOcelotIsRunningOnKestrelWithCustomBodyMaxSize(long customBodyMaxSize) + { + _realServer = Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseKestrel() + .ConfigureKestrel((_, options) => + { + options.Limits.MaxRequestBodySize = customBodyMaxSize; + }) + .ConfigureAppConfiguration((hostingContext, config) => + { + config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath); + var env = hostingContext.HostingEnvironment; + config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: false); + config.AddJsonFile(_ocelotConfigFileName, optional: true, reloadOnChange: false); + config.AddEnvironmentVariables(); + }) + .ConfigureServices(s => + { + s.AddOcelot(); + }) + .Configure(app => + { + app.UseOcelot().Wait(); + }) + .UseUrls("http://localhost:5001"); + }).Build(); + _realServer.Start(); + + _ocelotClient = new HttpClient + { + BaseAddress = new Uri("http://localhost:5001"), + }; + } + + private void GivenOcelotIsRunningOnHttpSysWithCustomBodyMaxSize(long customBodyMaxSize) + { + _realServer = Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webBuilder => + { +#pragma warning disable CA1416 // Validate platform compatibility + webBuilder.UseHttpSys(options => + { + options.MaxRequestBodySize = customBodyMaxSize; + }) +#pragma warning restore CA1416 // Validate platform compatibility + .ConfigureAppConfiguration((hostingContext, config) => + { + config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath); + var env = hostingContext.HostingEnvironment; + config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: false); + config.AddJsonFile(_ocelotConfigFileName, optional: true, reloadOnChange: false); + config.AddEnvironmentVariables(); + }) + .ConfigureServices(s => + { + s.AddOcelot(); + }) + .Configure(app => + { + app.UseOcelot().Wait(); + }) + .UseUrls("http://localhost:5001"); + }).Build(); + _realServer.Start(); + + _ocelotClient = new HttpClient + { + BaseAddress = new Uri("http://localhost:5001"), + }; + } +} diff --git a/test/Ocelot.AcceptanceTests/ServiceDiscoveryTests.cs b/test/Ocelot.AcceptanceTests/ServiceDiscoveryTests.cs index 79e0dd505..c37333cf2 100644 --- a/test/Ocelot.AcceptanceTests/ServiceDiscoveryTests.cs +++ b/test/Ocelot.AcceptanceTests/ServiceDiscoveryTests.cs @@ -1,7 +1,8 @@ -using Consul; -using Microsoft.AspNetCore.Http; -using Newtonsoft.Json; -using Ocelot.Configuration.File; +using Consul; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Ocelot.Configuration.File; +using System.Text.RegularExpressions; namespace Ocelot.AcceptanceTests { @@ -11,14 +12,19 @@ public class ServiceDiscoveryTests : IDisposable private readonly List _consulServices; private int _counterOne; private int _counterTwo; + private int _counterConsul; private static readonly object SyncLock = new(); private string _downstreamPath; private string _receivedToken; private readonly ServiceHandler _serviceHandler; + private readonly ServiceHandler _serviceHandler2; + private readonly ServiceHandler _consulHandler; public ServiceDiscoveryTests() { _serviceHandler = new ServiceHandler(); + _serviceHandler2 = new ServiceHandler(); + _consulHandler = new ServiceHandler(); _steps = new Steps(); _consulServices = new List(); } @@ -83,7 +89,7 @@ public void should_use_consul_service_discovery_and_load_balance_request() this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithConsul()) @@ -139,7 +145,7 @@ public void should_handle_request_to_consul_for_downstream_service_and_make_requ }; this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithConsul()) @@ -190,7 +196,7 @@ public void should_handle_request_to_consul_for_downstream_service_and_make_requ }; this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/something", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithConsul()) @@ -250,7 +256,7 @@ public void should_use_consul_service_discovery_and_load_balance_request_no_re_r this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithConsul()) @@ -308,7 +314,7 @@ public void should_use_token_to_make_request_to_consul() }; this.Given(_ => GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(_ => GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) .And(_ => GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) .And(_ => _steps.GivenThereIsAConfiguration(configuration)) .And(_ => _steps.GivenOcelotIsRunningWithConsul()) @@ -379,7 +385,7 @@ public void should_send_request_to_service_after_it_becomes_available_in_consul( this.Given(x => x.GivenProductServiceOneIsRunning(downstreamServiceOneUrl, 200)) .And(x => x.GivenProductServiceTwoIsRunning(downstreamServiceTwoUrl, 200)) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne, serviceEntryTwo)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithConsul()) @@ -447,7 +453,7 @@ public void should_handle_request_to_poll_consul_for_downstream_service_and_make }; this.Given(x => x.GivenThereIsAServiceRunningOn(downstreamServiceOneUrl, "/api/home", 200, "Hello from Laura")) - .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl, serviceName)) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryOne)) .And(x => _steps.GivenThereIsAConfiguration(configuration)) .And(x => _steps.GivenOcelotIsRunningWithConsul()) @@ -455,11 +461,123 @@ public void should_handle_request_to_poll_consul_for_downstream_service_and_make .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) .BDDfy(); - } - - private void ThenTheTokenIs(string token) - { - _receivedToken.ShouldBe(token); + } + + [Theory] + [Trait("PR", "1944")] + [Trait("Issues", "849 1496")] + [InlineData("LeastConnection")] + [InlineData("RoundRobin")] + [InlineData("NoLoadBalancer")] + [InlineData("CookieStickySessions")] + public void Should_use_consul_service_discovery_based_on_upstream_host(string loadBalancerType) + { + // Simulate two DIFFERENT downstream services (e.g. product services for US and EU markets) + // with different ServiceNames (e.g. product-us and product-eu), + // UpstreamHost is used to determine which ServiceName to use when making a request to Consul (e.g. Host: us-shop goes to product-us) + var consulPort = PortFinder.GetRandomPort(); + var servicePortUS = PortFinder.GetRandomPort(); + var servicePortEU = PortFinder.GetRandomPort(); + var serviceNameUS = "product-us"; + var serviceNameEU = "product-eu"; + var downstreamServiceUrlUS = $"http://localhost:{servicePortUS}"; + var downstreamServiceUrlEU = $"http://localhost:{servicePortEU}"; + var upstreamHostUS = "us-shop"; + var upstreamHostEU = "eu-shop"; + var publicUrlUS = $"http://{upstreamHostUS}"; + var publicUrlEU = $"http://{upstreamHostEU}"; + var responseBodyUS = "Phone chargers with US plug"; + var responseBodyEU = "Phone chargers with EU plug"; + var fakeConsulServiceDiscoveryUrl = $"http://localhost:{consulPort}"; + var serviceEntryUS = new ServiceEntry + { + Service = new AgentService + { + Service = serviceNameUS, + Address = "localhost", + Port = servicePortUS, + ID = Guid.NewGuid().ToString(), + Tags = ["US"], + }, + }; + var serviceEntryEU = new ServiceEntry + { + Service = new AgentService + { + Service = serviceNameEU, + Address = "localhost", + Port = servicePortEU, + ID = Guid.NewGuid().ToString(), + Tags = ["EU"], + }, + }; + + var configuration = new FileConfiguration + { + Routes = + [ + new() + { + DownstreamPathTemplate = "/products", + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod = ["Get"], + UpstreamHost = upstreamHostUS, + ServiceName = serviceNameUS, + LoadBalancerOptions = new() { Type = loadBalancerType }, + }, + new() + { + DownstreamPathTemplate = "/products", + DownstreamScheme = "http", + UpstreamPathTemplate = "/", + UpstreamHttpMethod = ["Get"], + UpstreamHost = upstreamHostEU, + ServiceName = serviceNameEU, + LoadBalancerOptions = new() { Type = loadBalancerType }, + }, + ], + GlobalConfiguration = new() + { + ServiceDiscoveryProvider = new() + { + Scheme = "http", + Host = "localhost", + Port = consulPort, + }, + }, + }; + + // Ocelot request for http://us-shop/ should find 'product-us' in Consul, call /products and return "Phone chargers with US plug" + // Ocelot request for http://eu-shop/ should find 'product-eu' in Consul, call /products and return "Phone chargers with EU plug" + this.Given(x => x._serviceHandler.GivenThereIsAServiceRunningOn(downstreamServiceUrlUS, "/products", MapGet("/products", responseBodyUS))) + .And(x => x._serviceHandler2.GivenThereIsAServiceRunningOn(downstreamServiceUrlEU, "/products", MapGet("/products", responseBodyEU))) + .And(x => x.GivenThereIsAFakeConsulServiceDiscoveryProvider(fakeConsulServiceDiscoveryUrl)) + .And(x => x.GivenTheServicesAreRegisteredWithConsul(serviceEntryUS, serviceEntryEU)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningWithConsul(publicUrlUS, publicUrlEU)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop for the first time") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(1)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop for the first time") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(2)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlUS), "When I get US shop again") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(3)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyUS)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway(publicUrlEU), "When I get EU shop again") + .Then(x => x.ThenConsulShouldHaveBeenCalledTimes(4)) + .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(responseBodyEU)) + .BDDfy(); + } + + private void ThenTheTokenIs(string token) + { + _receivedToken.ShouldBe(token); } private void WhenIAddAServiceBackIn(ServiceEntry serviceEntryTwo) @@ -482,6 +600,7 @@ private void GivenIResetCounters() { _counterOne = 0; _counterTwo = 0; + _counterConsul = 0; } private void ThenBothServicesCalledRealisticAmountOfTimes(int bottom, int top) @@ -504,24 +623,36 @@ private void GivenTheServicesAreRegisteredWithConsul(params ServiceEntry[] servi } } - private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url, string serviceName) + private void GivenThereIsAFakeConsulServiceDiscoveryProvider(string url) { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + _consulHandler.GivenThereIsAServiceRunningOn(url, async context => { - if (context.Request.Path.Value == $"/v1/health/service/{serviceName}") + if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) { - if (context.Request.Headers.TryGetValue("X-Consul-Token", out var values)) - { - _receivedToken = values.First(); - } + _receivedToken = values.First(); + } + + // Parse the request path to get the service name + var pathMatch = Regex.Match(context.Request.Path.Value, "/v1/health/service/(?[^/]+)"); + if (pathMatch.Success) + { + _counterConsul++; - var json = JsonConvert.SerializeObject(_consulServices); + // Use the parsed service name to filter the registered Consul services + var serviceName = pathMatch.Groups["serviceName"].Value; + var services = _consulServices.Where(x => x.Service.Service == serviceName).ToList(); + var json = JsonConvert.SerializeObject(services); context.Response.Headers.Append("Content-Type", "application/json"); await context.Response.WriteAsync(json); } }); } + private void ThenConsulShouldHaveBeenCalledTimes(int expected) + { + _counterConsul.ShouldBe(expected); + } + private void GivenProductServiceOneIsRunning(string url, int statusCode) { _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => @@ -547,7 +678,7 @@ private void GivenProductServiceOneIsRunning(string url, int statusCode) private void GivenProductServiceTwoIsRunning(string url, int statusCode) { - _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + _serviceHandler2.GivenThereIsAServiceRunningOn(url, async context => { try { @@ -587,9 +718,26 @@ private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int }); } + private RequestDelegate MapGet(string path, string responseBody) => async context => + { + var downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + if (downstreamPath == path) + { + context.Response.StatusCode = 200; + await context.Response.WriteAsync(responseBody); + } + else + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Not Found"); + } + }; + public void Dispose() { _serviceHandler?.Dispose(); + _serviceHandler2?.Dispose(); + _consulHandler?.Dispose(); _steps.Dispose(); } } diff --git a/test/Ocelot.AcceptanceTests/ServiceHandler.cs b/test/Ocelot.AcceptanceTests/ServiceHandler.cs index 9ba0d0fe4..c2e10a819 100644 --- a/test/Ocelot.AcceptanceTests/ServiceHandler.cs +++ b/test/Ocelot.AcceptanceTests/ServiceHandler.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using System.Security.Authentication; namespace Ocelot.AcceptanceTests { @@ -46,18 +45,12 @@ public void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, Reque _builder.Start(); } - public void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, RequestDelegate del, int port, HttpProtocols protocols) + public void GivenThereIsAServiceRunningOnWithKestrelOptions(string baseUrl, string basePath, Action options, RequestDelegate del) { _builder = new WebHostBuilder() .UseUrls(baseUrl) .UseKestrel() - .ConfigureKestrel(serverOptions => - { - serverOptions.Listen(IPAddress.Loopback, port, listenOptions => - { - listenOptions.Protocols = protocols; - }); - }) + .ConfigureKestrel(options ?? WithDefaultKestrelServerOptions) // ! .UseContentRoot(Directory.GetCurrentDirectory()) .UseIISIntegration() .Configure(app => @@ -70,32 +63,8 @@ public void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, Reque _builder.Start(); } - public void GivenThereIsAServiceRunningOnUsingHttps(string baseUrl, string basePath, RequestDelegate del, int port, HttpProtocols protocols) + internal void WithDefaultKestrelServerOptions(KestrelServerOptions options) { - _builder = new WebHostBuilder() - .UseUrls(baseUrl) - .UseKestrel() - .ConfigureKestrel(serverOptions => - { - serverOptions.Listen(IPAddress.Loopback, port, listenOptions => - { - listenOptions.UseHttps("mycert.pfx", "password", options => - { - options.SslProtocols = SslProtocols.Tls12; - }); - listenOptions.Protocols = protocols; - }); - }) - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseIISIntegration() - .Configure(app => - { - app.UsePathBase(basePath); - app.Run(del); - }) - .Build(); - - _builder.Start(); } public void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, string fileName, string password, int port, RequestDelegate del) diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs index e6265b93c..cacbd5f43 100644 --- a/test/Ocelot.AcceptanceTests/Steps.cs +++ b/test/Ocelot.AcceptanceTests/Steps.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Ocelot.AcceptanceTests.Caching; @@ -18,7 +19,6 @@ using Ocelot.LoadBalancer.LoadBalancers; using Ocelot.Logging; using Ocelot.Middleware; -using Ocelot.Multiplexer; using Ocelot.Provider.Consul; using Ocelot.Provider.Eureka; using Ocelot.Provider.Polly; @@ -58,6 +58,13 @@ public Steps() _ocelotConfigFileName = $"{Guid.NewGuid():N}-ocelot.json"; } + protected static string DownstreamUrl(int port) => $"{Uri.UriSchemeHttp}://localhost:{port}"; + + protected static FileConfiguration GivenConfiguration(params FileRoute[] routes) => new() + { + Routes = new(routes), + }; + public async Task ThenConfigShouldBe(FileConfiguration fileConfig) { var internalConfigCreator = _ocelotServer.Host.Services.GetService(); @@ -241,13 +248,10 @@ public void GivenOcelotIsRunning() /// /// The type. /// The delegate object to load balancer factory. - public void GivenOcelotIsRunningWithCustomLoadBalancer( - Func loadBalancerFactoryFunc) + public void GivenOcelotIsRunningWithCustomLoadBalancer(Func loadBalancerFactoryFunc) where T : ILoadBalancer { - _webHostBuilder = new WebHostBuilder(); - - _webHostBuilder + _webHostBuilder = new WebHostBuilder() .ConfigureAppConfiguration((hostingContext, config) => { config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath); @@ -265,14 +269,18 @@ public void GivenOcelotIsRunningWithCustomLoadBalancer( .Configure(app => { app.UseOcelot().Wait(); }); _ocelotServer = new TestServer(_webHostBuilder); - _ocelotClient = _ocelotServer.CreateClient(); } - public void GivenOcelotIsRunningWithConsul() + public void GivenOcelotIsRunningWithConsul(params string[] urlsToListenOn) { _webHostBuilder = new WebHostBuilder(); + if (urlsToListenOn?.Length > 0) + { + _webHostBuilder.UseUrls(urlsToListenOn); + } + _webHostBuilder .ConfigureAppConfiguration((hostingContext, config) => { @@ -502,36 +510,6 @@ public void GivenOcelotIsRunningWithSpecificHandlersRegisteredInDi() _ocelotClient = _ocelotServer.CreateClient(); } - public void GivenOcelotIsRunningWithSpecificAggregatorsRegisteredInDi() - where TAggregator : class, IDefinedAggregator - where TDependency : class - { - _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, true, false); - config.AddEnvironmentVariables(); - }) - .ConfigureServices(s => - { - s.AddSingleton(_webHostBuilder); - s.AddSingleton(); - s.AddOcelot() - .AddSingletonDefinedAggregator(); - }) - .Configure(a => { a.UseOcelot().Wait(); }); - - _ocelotServer = new TestServer(_webHostBuilder); - - _ocelotClient = _ocelotServer.CreateClient(); - } - public void GivenOcelotIsRunningWithGlobalHandlersRegisteredInDi() where TOne : DelegatingHandler where TWo : DelegatingHandler @@ -667,11 +645,14 @@ public void ThenTheReasonPhraseIs(string expected) } public void GivenOcelotIsRunningWithServices(Action configureServices) + => GivenOcelotIsRunningWithServices(configureServices, null); + + public void GivenOcelotIsRunningWithServices(Action configureServices, Action configureApp) { _webHostBuilder = new WebHostBuilder() .ConfigureAppConfiguration(WithBasicConfiguration) .ConfigureServices(configureServices ?? WithAddOcelot) - .Configure(WithUseOcelot); + .Configure(configureApp ?? WithUseOcelot); _ocelotServer = new TestServer(_webHostBuilder); _ocelotClient = _ocelotServer.CreateClient(); } diff --git a/test/Ocelot.UnitTests/Configuration/RouteKeyCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/RouteKeyCreatorTests.cs index 54f63dd4c..8326fb5a0 100644 --- a/test/Ocelot.UnitTests/Configuration/RouteKeyCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/RouteKeyCreatorTests.cs @@ -1,6 +1,6 @@ -using Ocelot.Configuration.Creator; -using Ocelot.Configuration.File; -using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Configuration.Creator; +using Ocelot.Configuration.File; +using Ocelot.LoadBalancer.LoadBalancers; namespace Ocelot.UnitTests.Configuration { @@ -16,7 +16,7 @@ public RouteKeyCreatorTests() } [Fact] - public void should_return_sticky_session_key() + public void Should_return_sticky_session_key() { var route = new FileRoute { @@ -29,35 +29,85 @@ public void should_return_sticky_session_key() this.Given(_ => GivenThe(route)) .When(_ => WhenICreate()) - .Then(_ => ThenTheResultIs($"{nameof(CookieStickySessions)}:{route.LoadBalancerOptions.Key}")) + .Then(_ => ThenTheResultIs("CookieStickySessions:testy")) .BDDfy(); } [Fact] - public void should_return_re_route_key() + public void Should_return_route_key() { var route = new FileRoute { UpstreamPathTemplate = "/api/product", - UpstreamHttpMethod = new List { "GET", "POST", "PUT" }, - DownstreamHostAndPorts = new List + UpstreamHttpMethod = ["GET", "POST", "PUT"], + DownstreamHostAndPorts = + [ + new("localhost", 8080), + new("localhost", 4430), + ], + }; + + this.Given(_ => GivenThe(route)) + .When(_ => WhenICreate()) + .Then(_ => ThenTheResultIs("GET,POST,PUT|/api/product|no-host|localhost:8080,localhost:4430|no-svc-ns|no-svc-name|no-lb-type|no-lb-key")) + .BDDfy(); + } + + [Fact] + public void Should_return_route_key_with_upstream_host() + { + var route = new FileRoute + { + UpstreamHost = "my-host", + UpstreamPathTemplate = "/api/product", + UpstreamHttpMethod = ["GET", "POST", "PUT"], + DownstreamHostAndPorts = + [ + new("localhost", 8080), + new("localhost", 4430), + ], + }; + + this.Given(_ => GivenThe(route)) + .When(_ => WhenICreate()) + .Then(_ => ThenTheResultIs("GET,POST,PUT|/api/product|my-host|localhost:8080,localhost:4430|no-svc-ns|no-svc-name|no-lb-type|no-lb-key")) + .BDDfy(); + } + + [Fact] + public void Should_return_route_key_with_svc_name() + { + var route = new FileRoute + { + UpstreamPathTemplate = "/api/product", + UpstreamHttpMethod = ["GET", "POST", "PUT"], + ServiceName = "products-service", + }; + + this.Given(_ => GivenThe(route)) + .When(_ => WhenICreate()) + .Then(_ => ThenTheResultIs("GET,POST,PUT|/api/product|no-host|no-host-and-port|no-svc-ns|products-service|no-lb-type|no-lb-key")) + .BDDfy(); + } + + [Fact] + public void Should_return_route_key_with_load_balancer_options() + { + var route = new FileRoute + { + UpstreamPathTemplate = "/api/product", + UpstreamHttpMethod = ["GET", "POST", "PUT"], + ServiceName = "products-service", + LoadBalancerOptions = new FileLoadBalancerOptions { - new() - { - Host = "localhost", - Port = 123, - }, - new() - { - Host = "localhost", - Port = 123, - }, + Type = nameof(LeastConnection), + Key = "testy", }, }; this.Given(_ => GivenThe(route)) .When(_ => WhenICreate()) - .Then(_ => ThenTheResultIs($"{route.UpstreamPathTemplate}|{string.Join(',', route.UpstreamHttpMethod)}|{string.Join(',', route.DownstreamHostAndPorts.Select(x => $"{x.Host}:{x.Port}"))}")) + .Then(_ => ThenTheResultIs("GET,POST,PUT|/api/product|no-host|no-host-and-port|no-svc-ns|products-service|LeastConnection|testy")) .BDDfy(); } diff --git a/test/Ocelot.UnitTests/Multiplexing/MultiplexingMiddlewareTests.cs b/test/Ocelot.UnitTests/Multiplexing/MultiplexingMiddlewareTests.cs index a7e6d2cb2..bd513b45a 100644 --- a/test/Ocelot.UnitTests/Multiplexing/MultiplexingMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/Multiplexing/MultiplexingMiddlewareTests.cs @@ -1,38 +1,47 @@ using Microsoft.AspNetCore.Http; +using Moq.Protected; using Ocelot.Configuration; using Ocelot.Configuration.Builder; +using Ocelot.Configuration.File; using Ocelot.DownstreamRouteFinder.UrlMatcher; using Ocelot.Logging; using Ocelot.Middleware; using Ocelot.Multiplexer; +using System.Reflection; +using System.Security.Claims; +using System.Text; namespace Ocelot.UnitTests.Multiplexing { public class MultiplexingMiddlewareTests { - private readonly MultiplexingMiddleware _middleware; + private MultiplexingMiddleware _middleware; private Ocelot.DownstreamRouteFinder.DownstreamRouteHolder _downstreamRoute; private int _count; private readonly HttpContext _httpContext; + private readonly Mock factory; + private readonly Mock aggregator; + private readonly Mock loggerFactory; + private readonly Mock logger; public MultiplexingMiddlewareTests() { _httpContext = new DefaultHttpContext(); - var factory = new Mock(); - var aggregator = new Mock(); + factory = new Mock(); + aggregator = new Mock(); factory.Setup(x => x.Get(It.IsAny())).Returns(aggregator.Object); - var loggerFactory = new Mock(); - var logger = new Mock(); + loggerFactory = new Mock(); + logger = new Mock(); loggerFactory.Setup(x => x.CreateLogger()).Returns(logger.Object); - Task Next(HttpContext context) => Task.FromResult(_count++); _middleware = new MultiplexingMiddleware(Next, loggerFactory.Object, factory.Object); } + private Task Next(HttpContext context) => Task.FromResult(_count++); + [Fact] public void should_multiplex() { - var route = new RouteBuilder().WithDownstreamRoute(new DownstreamRouteBuilder().Build()).WithDownstreamRoute(new DownstreamRouteBuilder().Build()).Build(); - + var route = GivenDefaultRoute(2); this.Given(x => GivenTheFollowing(route)) .When(x => WhenIMultiplex()) .Then(x => ThePipelineIsCalled(2)) @@ -50,15 +59,288 @@ public void should_not_multiplex() .BDDfy(); } + [Fact] + [Trait("Bug", "1396")] + public void CreateThreadContext_CopyUser_ToTarget() + { + // Arrange + GivenUser("test", "Copy", nameof(CreateThreadContext_CopyUser_ToTarget)); + + // Act + var method = _middleware.GetType().GetMethod("CreateThreadContext", BindingFlags.NonPublic | BindingFlags.Static); + var actual = (HttpContext)method.Invoke(_middleware, [_httpContext]); + + // Assert + AssertUsers(actual); + } + + [Fact] + [Trait("Bug", "1396")] + public async Task Invoke_ContextUser_ForwardedToDownstreamContext() + { + // Setup + HttpContext actualContext = null; + _middleware = new MultiplexingMiddleware(NextMe, loggerFactory.Object, factory.Object); + Task NextMe(HttpContext context) + { + actualContext = context; + return Next(context); + } + + // Arrange + GivenUser("test", "Invoke", nameof(Invoke_ContextUser_ForwardedToDownstreamContext)); + GivenTheFollowing(GivenDefaultRoute(2)); + + // Act + await WhenIMultiplex(); + + // Assert + ThePipelineIsCalled(2); + AssertUsers(actualContext); + } + + [Fact] + [Trait("PR", "1826")] + public async Task Should_Not_Copy_Context_If_One_Downstream_Route() + { + _middleware = new MultiplexingMiddleware(NextMe, loggerFactory.Object, factory.Object); + Task NextMe(HttpContext context) + { + Assert.Equal(_httpContext, context); + return Next(context); + } + + // Arrange + GivenUser("test", "Invoke", nameof(Should_Not_Copy_Context_If_One_Downstream_Route)); + GivenTheFollowing(GivenDefaultRoute(1)); + + // Act + await WhenIMultiplex(); + + // Assert + ThePipelineIsCalled(1); + + } + + [Fact] + [Trait("PR", "1826")] + public async Task Should_Call_ProcessSingleRoute_Once_If_One_Downstream_Route() + { + var mock = MockMiddlewareFactory(null, null); + + _middleware = mock.Object; + + // Arrange + GivenUser("test", "Invoke", nameof(Should_Call_ProcessSingleRoute_Once_If_One_Downstream_Route)); + GivenTheFollowing(GivenDefaultRoute(1)); + + // Act + await WhenIMultiplex(); + + // Assert + mock.Protected().Verify("ProcessSingleRouteAsync", Times.Once(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + [Theory] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [Trait("PR", "1826")] + public async Task Should_Not_Call_ProcessSingleRoute_If_More_Than_One_Downstream_Route(int routesCount) + { + var mock = MockMiddlewareFactory(null, null); + + // Arrange + GivenUser("test", "Invoke", nameof(Should_Not_Call_ProcessSingleRoute_If_More_Than_One_Downstream_Route)); + GivenTheFollowing(GivenDefaultRoute(routesCount)); + + // Act + await WhenIMultiplex(); + + // Assert + mock.Protected().Verify("ProcessSingleRouteAsync", Times.Never(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + [Theory] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [Trait("PR", "1826")] + public async Task Should_Create_As_Many_Contexts_As_Routes_And_Map_Is_Called_Once(int routesCount) + { + var mock = MockMiddlewareFactory(routesCount, null); + + // Arrange + GivenUser("test", "Invoke", nameof(Should_Create_As_Many_Contexts_As_Routes_And_Map_Is_Called_Once)); + GivenTheFollowing(GivenDefaultRoute(routesCount)); + + // Act + await WhenIMultiplex(); + + // Assert + mock.Protected().Verify("MapAsync", Times.Once(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.Is>(list => list.Count == routesCount) + ); + } + + [Fact] + [Trait("PR", "1826")] + public async Task Should_Not_Call_ProcessSingleRoute_Or_Map_If_No_Route() + { + var mock = MockMiddlewareFactory(null, null); + + // Arrange + GivenUser("test", "Invoke", nameof(Should_Not_Call_ProcessSingleRoute_Or_Map_If_No_Route)); + GivenTheFollowing(GivenDefaultRoute(0)); + + // Act + await WhenIMultiplex(); + + // Assert + mock.Protected().Verify("ProcessSingleRouteAsync", Times.Never(), + ItExpr.IsAny(), + ItExpr.IsAny()); + + mock.Protected().Verify("MapAsync", Times.Never(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny>()); + } + + [Fact] + [Trait("PR", "1826")] + public async Task If_Using_3_Routes_WithAggregator_ProcessSingleRoute_Is_Never_Called_Map_Once_And_Pipeline_3_Times() + { + var mock = MockMiddlewareFactory(null, AggregateRequestDelegateFactory()); + + // Arrange + GivenUser("test", "Invoke", nameof(If_Using_3_Routes_WithAggregator_ProcessSingleRoute_Is_Never_Called_Map_Once_And_Pipeline_3_Times)); + GivenTheFollowing(GivenRoutesWithAggregator()); + + // Act + await WhenIMultiplex(); + + mock.Protected().Verify("ProcessSingleRouteAsync", Times.Never(), + ItExpr.IsAny(), + ItExpr.IsAny()); + + mock.Protected().Verify("MapAsync", Times.Once(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny>()); + + ThePipelineIsCalled(3); + } + + private RequestDelegate AggregateRequestDelegateFactory() + { + return context => + { + var responseContent = @"[{""id"":1,""writerId"":1,""postId"":2,""text"":""text1""},{""id"":2,""writerId"":1,""postId"":2,""text"":""text2""}]"; + context.Items.Add("DownstreamResponse", new DownstreamResponse(new StringContent(responseContent, Encoding.UTF8, "application/json"), HttpStatusCode.OK, new List
(), "test")); + + if (!context.Items.ContainsKey("TemplatePlaceholderNameAndValues")) + { + context.Items.Add("TemplatePlaceholderNameAndValues", new List()); + } + + _count++; + return Task.CompletedTask; + }; + } + + private Mock MockMiddlewareFactory(int? downstreamRoutesCount, RequestDelegate requestDelegate) + { + requestDelegate ??= Next; + + var mock = new Mock(requestDelegate, loggerFactory.Object, factory.Object) { CallBase = true }; + + mock.Protected().Setup("MapAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + downstreamRoutesCount == null ? ItExpr.IsAny>() : ItExpr.Is>(list => list.Count == downstreamRoutesCount) + ).Returns(Task.CompletedTask).Verifiable(); + + mock.Protected().Setup("ProcessSingleRouteAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ).Returns(Task.CompletedTask).Verifiable(); + + _middleware = mock.Object; + return mock; + } + + private void GivenUser(string authentication, string name, string role) + { + var user = new ClaimsPrincipal(); + user.AddIdentity(new(authentication, name, role)); + _httpContext.User = user; + } + + private void AssertUsers(HttpContext actual) + { + Assert.NotNull(actual); + Assert.Same(_httpContext.User, actual.User); + Assert.NotNull(actual.User.Identity); + var identity = _httpContext.User.Identity as ClaimsIdentity; + var actualIdentity = actual.User.Identity as ClaimsIdentity; + Assert.Equal(identity.AuthenticationType, actualIdentity.AuthenticationType); + Assert.Equal(identity.NameClaimType, actualIdentity.NameClaimType); + Assert.Equal(identity.RoleClaimType, actualIdentity.RoleClaimType); + } + + private static Route GivenDefaultRoute(int count) + { + var b = new RouteBuilder(); + for (var i = 0; i < count; i++) + { + b.WithDownstreamRoute(new DownstreamRouteBuilder().Build()); + } + + return b.Build(); + } + + private static Route GivenRoutesWithAggregator() + { + var route1 = new DownstreamRouteBuilder().WithKey("Comments").Build(); + var route2 = new DownstreamRouteBuilder().WithKey("UserDetails").Build(); + var route3 = new DownstreamRouteBuilder().WithKey("PostDetails").Build(); + + var b = new RouteBuilder(); + b.WithDownstreamRoute(route1); + b.WithDownstreamRoute(route2); + b.WithDownstreamRoute(route3); + + b.WithAggregateRouteConfig( + [ + new AggregateRouteConfig + { RouteKey = "UserDetails", JsonPath = "$[*].writerId", Parameter = "userId" }, + new AggregateRouteConfig + { RouteKey = "PostDetails", JsonPath = "$[*].postId", Parameter = "postId" } + ]); + + b.WithAggregator("TestAggregator"); + + return b.Build(); + } + private void GivenTheFollowing(Route route) { _downstreamRoute = new Ocelot.DownstreamRouteFinder.DownstreamRouteHolder(new List(), route); _httpContext.Items.UpsertDownstreamRoute(_downstreamRoute); } - private void WhenIMultiplex() + private async Task WhenIMultiplex() { - _middleware.Invoke(_httpContext).GetAwaiter().GetResult(); + await _middleware.Invoke(_httpContext); } private void ThePipelineIsCalled(int expected) diff --git a/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs b/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs index 328fee6e5..624dc779e 100644 --- a/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs +++ b/test/Ocelot.UnitTests/Request/Mapper/RequestMapperTests.cs @@ -5,6 +5,7 @@ using Ocelot.Request.Mapper; using System.Security.Cryptography; using System.Text; +using Microsoft.Net.Http.Headers; namespace Ocelot.UnitTests.Request.Mapper; @@ -109,15 +110,61 @@ public void Should_handle_no_headers() .BDDfy(); } - [Fact] - public void Should_map_content() + [Theory] + [Trait("PR", "1972")] + [InlineData("GET")] + [InlineData("POST")] + public void Should_map_content(string method) { this.Given(_ => GivenTheInputRequestHasContent("This is my content")) - .And(_ => GivenTheInputRequestHasMethod("GET")) + .And(_ => GivenTheInputRequestHasMethod(method)) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasContent("This is my content")) + .And(_ => ThenTheMappedRequestHasContentLength("This is my content".Length)) + .BDDfy(); + } + + [Fact] + [Trait("PR", "1972")] + public void Should_map_chucked_content() + { + this.Given(_ => GivenTheInputRequestHasChunkedContent("This", " is my content")) + .And(_ => GivenTheInputRequestHasMethod("POST")) .And(_ => GivenTheInputRequestHasAValidUri()) .And(_ => GivenTheDownstreamRoute()) .When(_ => WhenMapped()) .And(_ => ThenTheMappedRequestHasContent("This is my content")) + .And(_ => ThenTheMappedRequestHasNoContentLength()) + .BDDfy(); + } + + [Fact] + [Trait("PR", "1972")] + public void Should_map_empty_content() + { + this.Given(_ => GivenTheInputRequestHasContent("")) + .And(_ => GivenTheInputRequestHasMethod("POST")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasContent("")) + .And(_ => ThenTheMappedRequestHasContentLength(0)) + .BDDfy(); + } + + [Fact] + [Trait("PR", "1972")] + public void Should_map_empty_chucked_content() + { + this.Given(_ => GivenTheInputRequestHasChunkedContent()) + .And(_ => GivenTheInputRequestHasMethod("POST")) + .And(_ => GivenTheInputRequestHasAValidUri()) + .And(_ => GivenTheDownstreamRoute()) + .When(_ => WhenMapped()) + .And(_ => ThenTheMappedRequestHasContent("")) + .And(_ => ThenTheMappedRequestHasNoContentLength()) .BDDfy(); } @@ -393,9 +440,18 @@ private void GivenTheInputRequestHasNoHeaders() private void GivenTheInputRequestHasContent(string content) { + _inputRequest.ContentLength = content.Length; _inputRequest.Body = new MemoryStream(Encoding.UTF8.GetBytes(content)); } + private void GivenTheInputRequestHasChunkedContent(params string[] chunks) + { + // ASP.Net Core decodes chucked streams, so that the input request just sees the decoded data + // Because of that, we just give a stream with the concatenated chunks to the test + _inputRequest.Body = new MemoryStream(Encoding.UTF8.GetBytes(string.Join("", chunks))); + _inputRequest.Headers.TransferEncoding = "chunked"; + } + private void GivenTheInputRequestHasNullContent() { _inputRequest.Body = null!; @@ -448,6 +504,17 @@ private void ThenTheMappedRequestHasContent(string expectedContent) _mappedRequest.Content.ReadAsStringAsync().GetAwaiter().GetResult().ShouldBe(expectedContent); } + private void ThenTheMappedRequestHasContentLength(long expectedLength) + { + Assert.NotNull(_mappedRequest.Content); + _mappedRequest.Content.Headers.ContentLength.ShouldBe(expectedLength); + } + + private void ThenTheMappedRequestHasNoContentLength() + { + _mappedRequest.Headers.TryGetValues(HeaderNames.ContentLength, out _).ShouldBeFalse(); + } + private void ThenTheMappedRequestHasNoContent() { _mappedRequest.Content.ShouldBeNull(); diff --git a/test/Ocelot.UnitTests/Requester/HttpRequesterMiddlewareTests.cs b/test/Ocelot.UnitTests/Requester/HttpRequesterMiddlewareTests.cs index 3dda1467c..717ca04ad 100644 --- a/test/Ocelot.UnitTests/Requester/HttpRequesterMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/Requester/HttpRequesterMiddlewareTests.cs @@ -34,7 +34,7 @@ public HttpRequesterMiddlewareTests() public void should_call_services_correctly() { this.Given(x => x.GivenTheRequestIs()) - .And(x => x.GivenTheRequesterReturns(new OkResponse(new HttpResponseMessage(System.Net.HttpStatusCode.OK)))) + .And(x => x.GivenTheRequesterReturns(new OkResponse(new HttpResponseMessage(HttpStatusCode.OK)))) .When(x => x.WhenICallTheMiddleware()) .Then(x => x.ThenTheDownstreamResponseIsSet()) .Then(x => InformationIsLogged()) @@ -56,12 +56,38 @@ public void should_log_downstream_internal_server_error() { this.Given(x => x.GivenTheRequestIs()) .And(x => x.GivenTheRequesterReturns( - new OkResponse(new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)))) + new OkResponse(new HttpResponseMessage(HttpStatusCode.InternalServerError)))) .When(x => x.WhenICallTheMiddleware()) .Then(x => x.WarningIsLogged()) .BDDfy(); } + + [Theory] + [Trait("Bug", "1953")] + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.PermanentRedirect)] + public void Should_LogInformation_when_status_is_less_than_BadRequest(HttpStatusCode status) + { + this.Given(x => x.GivenTheRequestIs()) + .And(x => x.GivenTheRequesterReturns(new OkResponse(new HttpResponseMessage(status)))) + .When(x => x.WhenICallTheMiddleware()) + .Then(x => x.InformationIsLogged()) + .BDDfy(); + } + [Theory] + [Trait("Bug", "1953")] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.NotFound)] + public void Should_LogWarning_when_status_is_BadRequest_or_greater(HttpStatusCode status) + { + this.Given(x => x.GivenTheRequestIs()) + .And(x => x.GivenTheRequesterReturns(new OkResponse(new HttpResponseMessage(status)))) + .When(x => x.WhenICallTheMiddleware()) + .Then(x => x.WarningIsLogged()) + .BDDfy(); + } + private void ThenTheErrorIsSet() { _httpContext.Items.Errors().Count.ShouldBeGreaterThan(0); @@ -103,18 +129,14 @@ private void ThenTheDownstreamResponseIsSet() private void WarningIsLogged() { _logger.Verify( - x => x.LogWarning( - It.IsAny>() - ), + x => x.LogWarning(It.IsAny>()), Times.Once); } private void InformationIsLogged() { _logger.Verify( - x => x.LogInformation( - It.IsAny>() - ), + x => x.LogInformation(It.IsAny>()), Times.Once); } } diff --git a/test/Ocelot.UnitTests/Responder/ErrorsToHttpStatusCodeMapperTests.cs b/test/Ocelot.UnitTests/Responder/ErrorsToHttpStatusCodeMapperTests.cs index 32c5c8a33..e80551117 100644 --- a/test/Ocelot.UnitTests/Responder/ErrorsToHttpStatusCodeMapperTests.cs +++ b/test/Ocelot.UnitTests/Responder/ErrorsToHttpStatusCodeMapperTests.cs @@ -81,7 +81,13 @@ public void should_return_bad_gateway_error(OcelotErrorCode errorCode) public void should_return_not_found(OcelotErrorCode errorCode) { ShouldMapErrorToStatusCode(errorCode, HttpStatusCode.NotFound); - } + } + + [Fact] + public void should_return_request_entity_too_large() + { + ShouldMapErrorsToStatusCode([OcelotErrorCode.PayloadTooLargeError], HttpStatusCode.RequestEntityTooLarge); + } [Fact] public void AuthenticationErrorsHaveHighestPriority() @@ -128,7 +134,7 @@ public void check_we_have_considered_all_errors_in_these_tests() // If this test fails then it's because the number of error codes has changed. // You should make the appropriate changes to the test cases here to ensure // they cover all the error codes, and then modify this assertion. - Enum.GetNames(typeof(OcelotErrorCode)).Length.ShouldBe(41, "Looks like the number of error codes has changed. Do you need to modify ErrorsToHttpStatusCodeMapper?"); + Enum.GetNames(typeof(OcelotErrorCode)).Length.ShouldBe(42, "Looks like the number of error codes has changed. Do you need to modify ErrorsToHttpStatusCodeMapper?"); } private void ShouldMapErrorToStatusCode(OcelotErrorCode errorCode, HttpStatusCode expectedHttpStatusCode)