diff --git a/ReleaseNotes.md b/ReleaseNotes.md
index 460241423..6698eb051 100644
--- a/ReleaseNotes.md
+++ b/ReleaseNotes.md
@@ -1,20 +1,63 @@
-## Hotfix release (version {0})
-> Default timeout vs the [Quality of Service](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html) feature
+## November-December 2023 (version {0}) aka [Sunny Koliada](https://www.google.com/search?q=winter+solstice) release
+> Codenamed as **[Sunny Koliada](https://www.bing.com/search?q=winter+solstice)**
-Special thanks to **Alvin Huang**, @huanguolin!
+### Focus On
-### About
-The bug is related to the **Quality of Service** feature (aka **QoS**) and the [HttpClient.Timeout](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout) property.
-- If JSON `QoSOptions` section is defined in the route config, then the bug is masked rather than active, and the timeout value is assigned from the [QoS TimeoutValue](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html#quality-of-service:~:text=%22TimeoutValue%22%3A%205000) property.
-- If the `QoSOptions` section **is not** defined in the route config or the [TimeoutValue](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html#quality-of-service:~:text=%22TimeoutValue%22%3A%205000) property is missing, then the bug is **active** and affects downstream requests that **never time out**.
+
+ System performance. System core performance review, redesign of system core related to routing and content streaming
-### Technical info
-In version [22.0](https://github.com/ThreeMammals/Ocelot/releases/tag/22.0.0), the bug was found in the explicit default constructor of the [FileQoSOptions](https://github.com/ThreeMammals/Ocelot/blob/main/src/Ocelot/Configuration/File/FileQoSOptions.cs) class with a maximum [TimeoutValue](https://github.com/ThreeMammals/Ocelot/blob/main/src/Ocelot/Configuration/File/FileQoSOptions.cs#L9). Previously, the default constructor was implicit with the default assignment of zero `0` to all `int` properties.
+ - Modification of the `RequestMapper` with a brand new `StreamHttpContent` class, in `Ocelot.Request.Mapper` namespace. The request body is no longer copied when it is handled by the API gateway, avoiding Out-of-Memory issues in pods/containers. This significantly reduces the gateway's memory consumption, and allows you to transfer content larger than 2 GB in streaming scenarios.
+ - Introduction of a new Message Invoker pool, in `Ocelot.Requester` namespace. We have replaced the [HttpClient](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) class with [HttpMessageInvoker](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpmessageinvoker), which is the base class for `HttpClient`. The overall logic for managing the pool has been simplified, resulting in a reduction in the number of CPU cycles.
+ - Full HTTP content buffering is deactivated, resulting in a 50% reduction in memory consumption and a performance improvement of around 10%. Content is no longer copied on the API gateway, avoiding Out-of-Memory issues.
+ - **TODO** Include screenshots from Production...
+
-The new explicit default constructor breaks the old implementation of [QoS TimeoutValue](https://github.com/ThreeMammals/Ocelot/blob/main/src/Ocelot/Requester/HttpClientBuilder.cs#L53-L55) logic, as our [QoS documentation](https://ocelot.readthedocs.io/en/latest/features/qualityofservice.html#quality-of-service:~:text=If%20you%20do%20not%20add%20a%20QoS%20section%2C%20QoS%20will%20not%20be%20used%2C%20however%20Ocelot%20will%20default%20to%20a%2090%20seconds%20timeout%20on%20all%20downstream%20requests.) states:
-![image](https://github.com/ThreeMammals/Ocelot/assets/12430413/2c6b2cd3-e1c6-4510-9e46-883468c140ec)
-**Finally**, the "default 90 second" logic for `HttpClient` breaks down when there are no **QoS** options and all requests on those routes are infinite, if, for example, downstream services are down or stuck.
+
+ Ocelot extra packages. Total 3 Ocelot packs were updated
+
+ - [Ocelot.Cache.CacheManager](https://github.com/ThreeMammals/Ocelot/tree/main/src/Ocelot.Cache.CacheManager): Introduced default cache key generator with improved performance (the `DefaultCacheKeyGenerator` class). Old version of `CacheKeyGenerator` had significant performance issue when reading full content of HTTP request for caching key calculation of MD5 hash value. This hash value was excluded from the caching key.
+ - [Ocelot.Provider.Kubernetes](https://github.com/ThreeMammals/Ocelot/tree/main/src/Ocelot.Provider.Kubernetes): Fixed long lasting breaking change being added in version [15.0.0](https://github.com/ThreeMammals/Ocelot/releases/tag/15.0.0), see commit https://github.com/ThreeMammals/Ocelot/commit/6e5471a714dddb0a3a40fbb97eac2810cee1c78d. The bug persisted for more than 3 years in versions **15.0.0-22.0.1**, being masked multiple times via class renaming! **Special Thanks to @ZisisTsatsas** who once again brought this issue to our attention, and our team finally realized that we had a breaking change and the provider was broken.
-#### The Bug Artifacts
-- Reported bug issue: [1833](https://github.com/ThreeMammals/Ocelot/issues/1833) by @huanguolin
-- Hotfix PR: [1834](https://github.com/ThreeMammals/Ocelot/pull/1834) by @huanguolin
+ - [Ocelot.Provider.Polly](https://github.com/ThreeMammals/Ocelot/tree/main/src/Ocelot.Provider.Polly): A minor changes without feature delivery. We are preparing for a major update to the package in the next release.
+
+
+
+ Middlewares. Total 8 Ocelot middlewares were updated
+
+ - `AuthenticationMiddleware`: Added new [Multiple Authentication Schemes](https://github.com/ThreeMammals/Ocelot/pull/1870) feature by @MayorSheFF
+ - `OutputCacheMiddleware`, `RequestIdMiddleware`: Added new [Cache by Header Value](https://github.com/ThreeMammals/Ocelot/pull/1172) by @EngRajabi, and redesigned as [Default CacheKeyGenerator](https://github.com/ThreeMammals/Ocelot/pull/1849) feature by @raman-m
+ - `DownstreamUrlCreatorMiddleware`: Fixed [bug](https://github.com/ThreeMammals/Ocelot/issues/748) for ending/omitting slash in path templates aka [Empty placeholders](https://github.com/ThreeMammals/Ocelot/pull/1911) feature by @AlyHKafoury
+ - `ConfigurationMiddleware`, `HttpRequesterMiddleware`, `ResponderMiddleware`: System upgrade for [Custom HttpMessageInvoker pooling](https://github.com/ThreeMammals/Ocelot/pull/1824) feature by @ggnaegi
+ - `DownstreamRequestInitialiserMiddleware`: System upgrade for [Performance of Request Mapper](https://github.com/ThreeMammals/Ocelot/pull/1724) feature by @ggnaegi
+
+
+
+ Documentation for Authentication, Caching, Kubernetes and Routing
+
+ - [Authentication](https://ocelot.readthedocs.io/en/latest/features/authentication.html)
+ - [Caching](https://ocelot.readthedocs.io/en/latest/features/caching.html)
+ - [Kubernetes](https://ocelot.readthedocs.io/en/latest/features/kubernetes.html)
+ - [Routing](https://ocelot.readthedocs.io/en/latest/features/routing.html)
+
+
+
+ Stabilization aka bug fixing
+
+ - See [all bugs](https://github.com/ThreeMammals/Ocelot/issues?q=is%3Aissue+milestone%3ANov-December%2723+is%3Aclosed+label%3Abug) of the [Nov-December'23](https://github.com/ThreeMammals/Ocelot/milestone/2) milestone
+
+
+
+ Testing
+
+ - The `Ocelot.Benchmarks` testing project has been updated with new `PayloadBenchmarks` and `ResponseBenchmarks` by @ggnaegi
+ - The `Ocelot.AcceptanceTests` testing project has been refactored by @raman-m using the new `AuthenticationSteps` class, and more refactoring will be done in future releases
+
+
+### Roadmap
+We would like to share our team's plans for the future regarding: development trends, ideas, community expectations, etc.
+- **Code Review and Performance Improvements**. Without a doubt, we care about code quality every day, following best development practices. And we review, test, refactor, and redesign features with overall performance in mind. In the next few releases (versions 23.x-24.0) we will take care of: generic providers, multiplexing middleware (Aggregation feature), memory management.
+- **Server-Sent Events protocol support**. There is a lot of community interest in this HTTP-based protocol.
+- **Long Polling for Consul provider**. [Consul](https://www.consul.io/) is our leading technology for service discovery. We are constantly improving the use cases for the `Ocelot.Provider.Consul` package and trying to improve the code inside the package.
+- **QoS feature refactoring**. [Polly](https://github.com/App-vNext/Polly/) was released with the new v.8.2+ after .NET 8. So we have to update `Ocelot.Provider.Polly` package taking into account new Polly behavior of redesigned features.
+- **Brainstorming** to redesign Rate Limiting, Websockets. More details in future release notes.
+- **Planning** of support for Swagger and gRPC proto. More details in future release notes.
diff --git a/codeanalysis.ruleset b/codeanalysis.ruleset
index 0198bd195..096807342 100644
--- a/codeanalysis.ruleset
+++ b/codeanalysis.ruleset
@@ -18,6 +18,7 @@
+
diff --git a/docs/conf.py b/docs/conf.py
index dca77bb40..ceea7cca2 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -7,9 +7,9 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Ocelot'
-copyright = ' 2023 ThreeMammals Ocelot team'
+copyright = ' 2016-2024 ThreeMammals Ocelot team'
author = 'Tom Pallister, Ocelot Core team at ThreeMammals'
-release = '22.0'
+release = '23.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
diff --git a/docs/features/authentication.rst b/docs/features/authentication.rst
index 3b2398e8d..0c4b8c19d 100644
--- a/docs/features/authentication.rst
+++ b/docs/features/authentication.rst
@@ -2,36 +2,90 @@ Authentication
==============
In order to authenticate Routes and subsequently use any of Ocelot's claims based features such as authorization or modifying the request with values from the token,
-users must register authentication services in their **Startup.cs** as usual but they provide a scheme (authentication provider key) with each registration e.g.
+users must register authentication services in their **Startup.cs** as usual but they provide `a scheme `_
+(authentication provider key) with each registration e.g.
.. code-block:: csharp
public void ConfigureServices(IServiceCollection services)
{
- var authenticationProviderKey = "TestKey";
+ const string AuthenticationProviderKey = "MyKey";
services
.AddAuthentication()
- .AddJwtBearer(authenticationProviderKey,
- options => { /* custom auth-setup */ });
+ .AddJwtBearer(AuthenticationProviderKey, options =>
+ {
+ // Custom Authentication setup via options initialization
+ });
}
-In this example "**TestKey**" is the scheme that this provider has been registered with. We then map this to a Route in the configuration e.g.
+In this example ``MyKey`` is `the scheme `_ that this provider has been registered with.
+We then map this to a Route in the configuration using the following `AuthenticationOptions `_ properties:
+
+* ``AuthenticationProviderKey`` is a string object, obsolete [#f1]_. This is legacy definition when you define :ref:`authentication-single`.
+* ``AuthenticationProviderKeys`` is an array of strings, the recommended definition of :ref:`authentication-multiple` feature.
+
+.. _authentication-single:
+
+Single Key aka Authentication Scheme [#f1]_
+-------------------------------------------
+
+ | Property: ``AuthenticationOptions.AuthenticationProviderKey``
+
+We map authentication provider to a Route in the configuration e.g.
.. code-block:: json
- "Routes": [{
- "AuthenticationOptions": {
- "AuthenticationProviderKey": "TestKey",
- "AllowedScopes": []
- }
- }]
+ "AuthenticationOptions": {
+ "AuthenticationProviderKey": "MyKey",
+ "AllowedScopes": []
+ }
-When Ocelot runs it will look at this Routes ``AuthenticationOptions.AuthenticationProviderKey`` and check that there is an authentication provider registered with the given key.
+When Ocelot runs it will look at this Routes ``AuthenticationProviderKey`` and check that there is an authentication provider registered with the given key.
If there isn't then Ocelot will not start up. If there is then the Route will use that provider when it executes.
If a Route is authenticated, Ocelot will invoke whatever scheme is associated with it while executing the authentication middleware.
If the request fails authentication, Ocelot returns a HTTP status code `401 Unauthorized `_.
+.. _authentication-multiple:
+
+Multiple Authentication Schemes [#f2]_
+--------------------------------------
+
+ | Property: ``AuthenticationOptions.AuthenticationProviderKeys``
+
+In real world of ASP.NET, apps may need to support multiple types of authentication by single Ocelot app instance.
+To register `multiple authentication schemes `_
+(`authentication provider keys `_) for each appropriate authentication provider, use and develop this abstract configuration of two or more schemes:
+
+.. code-block:: csharp
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ const string DefaultScheme = JwtBearerDefaults.AuthenticationScheme; // Bearer
+ services.AddAuthentication()
+ .AddJwtBearer(DefaultScheme, options => { /* JWT setup */ })
+ // AddJwtBearer, AddCookie, AddIdentityServerAuthentication etc.
+ .AddMyProvider("MyKey", options => { /* Custom auth setup */ });
+ }
+
+In this example, the ``MyKey`` and ``Bearer`` schemes represent the keys with which these providers were registered.
+We then map these schemes to a Route in the configuration as shown below.
+
+.. code-block:: json
+
+ "AuthenticationOptions": {
+ "AuthenticationProviderKeys": [ "Bearer", "MyKey" ] // The order matters!
+ "AllowedScopes": []
+ }
+
+Afterward, Ocelot applies all steps that are specified for ``AuthenticationProviderKey`` as :ref:`authentication-single`.
+
+**Note** that the order of the keys in an array definition does matter! We use a "First One Wins" authentication strategy.
+
+Finally, we would say that registering providers, initializing options, forwarding authentication artifacts can be a "real" coding challenge.
+If you're stuck or don't know what to do, just find inspiration in our `acceptance tests `_
+(currently for `Identity Server 4 `_ only) [#f3]_.
+
JWT Tokens
----------
@@ -41,7 +95,7 @@ If you want to authenticate using JWT tokens maybe from a provider like `Auth0 <
public void ConfigureServices(IServiceCollection services)
{
- var authenticationProviderKey = "TestKey";
+ var authenticationProviderKey = "MyKey";
services
.AddAuthentication()
.AddJwtBearer(authenticationProviderKey, options =>
@@ -56,12 +110,16 @@ Then map the authentication provider key to a Route in your configuration e.g.
.. code-block:: json
- "Routes": [{
- "AuthenticationOptions": {
- "AuthenticationProviderKey": "TestKey",
- "AllowedScopes": []
- }
- }]
+ "AuthenticationOptions": {
+ "AuthenticationProviderKeys": [ "MyKey" ],
+ "AllowedScopes": []
+ }
+
+Docs
+^^^^
+
+* Microsoft Learn: `Authentication and authorization in minimal APIs `_
+* Andrew Lock | .NET Escapades: `A look behind the JWT bearer authentication middleware in ASP.NET Core `_
Identity Server Bearer Tokens
-----------------------------
@@ -73,7 +131,7 @@ If you don't understand how to do this, please consult the IdentityServer `docum
public void ConfigureServices(IServiceCollection services)
{
- var authenticationProviderKey = "TestKey";
+ var authenticationProviderKey = "MyKey";
Action options = (opt) =>
{
opt.Authority = "https://whereyouridentityserverlives.com";
@@ -89,12 +147,10 @@ Then map the authentication provider key to a Route in your configuration e.g.
.. code-block:: json
- "Routes": [{
- "AuthenticationOptions": {
- "AuthenticationProviderKey": "TestKey",
- "AllowedScopes": []
- }
- }]
+ "AuthenticationOptions": {
+ "AuthenticationProviderKeys": [ "MyKey" ],
+ "AllowedScopes": []
+ }
Auth0 by Okta
-------------
@@ -137,8 +193,22 @@ If you add scopes to **AllowedScopes**, Ocelot will get all the user claims (fro
This is a way to restrict access to a Route on a per scope basis.
-More identity providers
------------------------
+Links
+-----
+
+* Microsoft Learn: `Overview of ASP.NET Core authentication `_
+* Microsoft Learn: `Authorize with a specific scheme in ASP.NET Core `_
+* Microsoft Learn: `Policy schemes in ASP.NET Core `_
+* Microsoft .NET Blog: `ASP.NET Core Authentication with IdentityServer4 `_
+
+Future
+------
We invite you to add more examples, if you have integrated with other identity providers and the integration solution is working.
Please, open `Show and tell `_ discussion in the repository.
+
+""""
+
+.. [#f1] Use the ``AuthenticationProviderKeys`` property instead of ``AuthenticationProviderKey`` one. We support this ``[Obsolete]`` property for backward compatibility and migration reasons. In future releases, the property may be removed as a breaking change.
+.. [#f2] "`Multiple authentication schemes `__" feature was requested in issues `740 `_, `1580 `_ and delivered as a part of `23.0 `_ release.
+.. [#f3] We would appreciate any new PRs to add extra acceptance tests for your custom scenarios with `multiple authentication schemes `__.
diff --git a/docs/features/caching.rst b/docs/features/caching.rst
index a7cd4096f..f2bf302ed 100644
--- a/docs/features/caching.rst
+++ b/docs/features/caching.rst
@@ -6,6 +6,9 @@ This is an amazing project that is solving a lot of caching problems. We would r
The following example shows how to add **CacheManager** to Ocelot so that you can do output caching.
+Install
+-------
+
First of all, add the following `NuGet package `_:
.. code-block:: powershell
@@ -26,15 +29,25 @@ The second thing you need to do something like the following to your ``Configure
.AddCacheManager(x => x.WithDictionaryHandle());
});
+Configuration
+-------------
+
Finally, in order to use caching on a route in your Route configuration add this setting:
.. code-block:: json
- "FileCacheOptions": { "TtlSeconds": 15, "Region": "europe-central" }
+ "FileCacheOptions": {
+ "TtlSeconds": 15,
+ "Region": "europe-central",
+ "Header": "Authorization"
+ }
In this example **TtlSeconds** is set to 15 which means the cache will expire after 15 seconds.
The **Region** represents a region of caching.
+Additionally, if a header name is defined in the **Header** property, that header value is looked up by the key (header name) in the ``HttpRequest`` headers,
+and if the header is found, its value will be included in caching key. This causes the cache to become invalid due to the header value changing.
+
If you look at the example `here `_ you can see how the cache manager is setup and then passed into the Ocelot ``AddCacheManager`` configuration method.
You can use any settings supported by the **CacheManager** package and just pass them in.
diff --git a/docs/features/kubernetes.rst b/docs/features/kubernetes.rst
index a2e17fd08..44411e589 100644
--- a/docs/features/kubernetes.rst
+++ b/docs/features/kubernetes.rst
@@ -2,23 +2,24 @@
:alt: K8s Logo
:width: 40
-|K8s Logo| Kubernetes
-=====================
+|K8s Logo| Kubernetes [#f1]_ aka K8s
+====================================
- Feature: :doc:`../features/servicediscovery`
+ A part of feature: :doc:`../features/servicediscovery` [#f2]_
-This feature was requested as part of `issue 345 `_ to add support for `Kubernetes `_ service discovery provider.
+Ocelot will call the `K8s `_ endpoints API in a given namespace to get all of the endpoints for a pod and then load balance across them.
+Ocelot used to use the services API to send requests to the `K8s `__ service but this was changed in `PR 1134 `_ because the service did not load balance as expected.
-Ocelot will call the K8s endpoints API in a given namespace to get all of the endpoints for a pod and then load balance across them.
-Ocelot used to use the services API to send requests to the K8s service but this was changed in `PR 1134 `_ because the service did not load balance as expected.
+Install
+-------
-The first thing you need to do is install the `NuGet package `_ that provides Kubernetes support in Ocelot:
+The first thing you need to do is install the `NuGet package `_ that provides **Kubernetes** [#f1]_ support in Ocelot:
.. code-block:: powershell
Install-Package Ocelot.Provider.Kubernetes
-Then add the following to your ConfigureServices method:
+Then add the following to your ``ConfigureServices`` method:
.. code-block:: csharp
@@ -41,21 +42,24 @@ K8s API server and token will read from pod.
kubectl create clusterrolebinding permissive-binding --clusterrole=cluster-admin --user=admin --user=kubelet --group=system:serviceaccounts
-The following example shows how to set up a Route that will work in Kubernetes.
+Configuration
+-------------
+
+The following examples show how to set up a Route that will work in Kubernetes.
The most important thing is the **ServiceName** which is made up of the Kubernetes service name.
We also need to set up the **ServiceDiscoveryProvider** in **GlobalConfiguration**.
+
+Kube default provider
+^^^^^^^^^^^^^^^^^^^^^
+
The example here shows a typical configuration:
.. code-block:: json
- {
"Routes": [
{
- "DownstreamPathTemplate": "/api/values",
- "DownstreamScheme": "http",
- "UpstreamPathTemplate": "/values",
"ServiceName": "downstreamservice",
- "UpstreamHttpMethod": [ "Get" ]
+ // ...
}
],
"GlobalConfiguration": {
@@ -63,25 +67,28 @@ The example here shows a typical configuration:
"Host": "192.168.0.13",
"Port": 443,
"Token": "txpc696iUhbVoudg164r93CxDTrKRVWG",
- "Namespace": "dev",
- "Type": "kube"
+ "Namespace": "Dev",
+ "Type": "Kube"
}
}
- }
-Service deployment in **Namespace** ``dev``, **ServiceDiscoveryProvider** type is ``kube``, you also can set ``pollkube`` **ServiceDiscoveryProvider** type.
+Service deployment in **Namespace** ``Dev``, **ServiceDiscoveryProvider** type is ``Kube``, you also can set :ref:`k8s-pollkube-provider` type.
Note: **Host**, **Port** and **Token** are no longer in use.
+.. _k8s-pollkube-provider:
+
+PollKube provider
+^^^^^^^^^^^^^^^^^
+
You use Ocelot to poll Kubernetes for latest service information rather than per request.
If you want to poll Kubernetes for the latest services rather than per request (default behaviour) then you need to set the following configuration:
.. code-block:: json
"ServiceDiscoveryProvider": {
- // ...
"Namespace": "dev",
- "Type": "pollkube",
- "PollingInterval": 100
+ "Type": "PollKube",
+ "PollingInterval": 100 // ms
}
The polling interval is in milliseconds and tells Ocelot how often to call Kubernetes for changes in service configuration.
@@ -92,14 +99,21 @@ This really depends on how volatile your services are.
We doubt it will matter for most people and polling may give a tiny performance improvement over calling Kubernetes per request.
There is no way for Ocelot to work these out for you.
+Global vs Route levels
+^^^^^^^^^^^^^^^^^^^^^^
+
If your downstream service resides in a different namespace, you can override the global setting at the Route-level by specifying a **ServiceNamespace**:
.. code-block:: json
"Routes": [
{
- // ...
"ServiceName": "downstreamservice",
"ServiceNamespace": "downstream-namespace"
}
]
+
+""""
+
+.. [#f1] `Wikipedia `_ | `K8s Website `_ | `K8s Documentation `_ | `K8s GitHub `_
+.. [#f2] This feature was requested as part of `issue 345 `_ to add support for `Kubernetes `_ :doc:`../features/servicediscovery` provider.
diff --git a/docs/features/routing.rst b/docs/features/routing.rst
index e2ca941bf..6de03d8e2 100644
--- a/docs/features/routing.rst
+++ b/docs/features/routing.rst
@@ -36,6 +36,8 @@ The **UpstreamPathTemplate** property is the URL that Ocelot will use to identif
The **UpstreamHttpMethod** is used so Ocelot can distinguish between requests with different HTTP verbs to the same URL.
You can set a specific list of HTTP methods or set an empty list to allow any of them.
+.. _routing-placeholders:
+
Placeholders
------------
@@ -43,7 +45,7 @@ In Ocelot you can add placeholders for variables to your Templates in the form o
The placeholder variable needs to be present in both the **DownstreamPathTemplate** and **UpstreamPathTemplate** properties.
When it is Ocelot will attempt to substitute the value in the **UpstreamPathTemplate** placeholder into the **DownstreamPathTemplate** for each request Ocelot processes.
-You can also do a `Catch All <#catch-all>`_ type of Route e.g.
+You can also do a :ref:`routing-catch-all` type of Route e.g.
.. code-block:: json
@@ -69,6 +71,33 @@ In order to change this you can specify on a per Route basis the following setti
This means that when Ocelot tries to match the incoming upstream URL with an upstream template the evaluation will be case sensitive.
+.. _routing-empty-placeholders:
+
+Empty Placeholders [#f1]_
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This is a special edge case of :ref:`routing-placeholders`, where the value of the placeholder is simply an empty string ``""``.
+
+For example, **Given a route**:
+
+.. code-block:: json
+
+ {
+ "UpstreamPathTemplate": "/invoices/{url}",
+ "DownstreamPathTemplate": "/api/invoices/{url}",
+ }
+
+.. role:: htm(raw)
+ :format: html
+
+| **Then**, it works correctly when ``{url}`` is specified: ``/invoices/123`` :htm:`→` ``/api/invoices/123``.
+| **And then**, there are two edge cases with empty placeholder value:
+
+* Also, it works when ``{url}`` is empty. We would expect upstream path ``/invoices/`` to route to downstream path ``/api/invoices/``
+* Moreover, it should work when omitting last slash. We also expect upstream ``/invoices`` to be routed to downstream ``/api/invoices``, which is intuitive to humans
+
+.. _routing-catch-all:
+
Catch All
---------
@@ -104,8 +133,10 @@ If you also have the Route below in your config then Ocelot would match it befor
]
}
-Upstream Host
--------------
+.. _routing-upstream-host:
+
+Upstream Host [#f2]_
+--------------------
This feature allows you to have Routes based on the *upstream host*.
This works by looking at the ``Host`` header the client has used and then using this as part of the information we use to identify a Route.
@@ -123,8 +154,6 @@ The Route above will only be matched when the ``Host`` header value is ``somedom
If you do not set **UpstreamHost** on a Route then any ``Host`` header will match it.
This means that if you have two Routes that are the same, apart from the **UpstreamHost**, where one is null and the other set Ocelot will favour the one that has been set.
-This feature was requested as part of `issue 216 `_.
-
Priority
--------
@@ -161,18 +190,10 @@ and
In the example above if you make a request into Ocelot on ``/goods/delete``, Ocelot will match ``/goods/delete`` Route.
Previously it would have matched ``/goods/{catchAll}``, because this is the first Route in the list!
-Dynamic Routing
----------------
-
-This feature was requested in `issue 340 `_.
-
-The idea is to enable dynamic routing when using a service discovery provider so you don't have to provide the Route config.
-See the docs :doc:`../features/servicediscovery` if this sounds interesting to you.
-
Query String Placeholders
-------------------------
-In addition to URL path `placeholders <#placeholders>`_ Ocelot is able to forward query string parameters with their processing in the form of ``{something}``.
+In addition to URL path :ref:`routing-placeholders` Ocelot is able to forward query string parameters with their processing in the form of ``{something}``.
Also, the query parameter placeholder needs to be present in both the **DownstreamPathTemplate** and **UpstreamPathTemplate** properties.
Placeholder replacement works bi-directionally between path and query strings, with some `restrictions <#restrictions-on-use>`_ on usage.
@@ -211,7 +232,7 @@ Note, the best practice is giving different placeholder name than the name of qu
Catch All Query String
^^^^^^^^^^^^^^^^^^^^^^
-Ocelot's routing also supports a *Catch All* style routing to forward all query string parameters.
+Ocelot's routing also supports a :ref:`routing-catch-all` style routing to forward all query string parameters.
The placeholder ``{everything}`` name does not matter, any name will work.
.. code-block:: json
@@ -224,6 +245,9 @@ The placeholder ``{everything}`` name does not matter, any name will work.
This entire query string routing feature is very useful in cases where the query string should not be transformed but rather routed without any changes,
such as OData filters and etc (see issue `1174 `_).
+**Note**, the ``{everything}`` placeholder can be empty while catching all query strings, because this is a part of the :ref:`routing-empty-placeholders` feature! [#f1]_
+Thus, upstream paths ``/contracts?`` and ``/contracts`` are routed to downstream path ``/apipath/contracts``, which has no query string at all.
+
Restrictions on use
^^^^^^^^^^^^^^^^^^^
@@ -268,8 +292,10 @@ Here are two user scenarios.
So, both ``{userId}`` placeholder and ``userId`` parameter **names are the same**!
Finally, the ``userId`` parameter is removed.
-Security Options
-----------------
+.. _routing-security-options:
+
+Security Options [#f3]_
+-----------------------
Ocelot allows you to manage multiple patterns for allowed/blocked IPs using the `IPAddressRange `_ package
with `MPL-2.0 License `_.
@@ -298,4 +324,17 @@ The current patterns managed are the following:
}
}
-This feature was requested as part of `issue 1400 `_.
+.. _routing-dynamic:
+
+Dynamic Routing [#f4]_
+----------------------
+
+The idea is to enable dynamic routing when using a :doc:`../features/servicediscovery` provider so you don't have to provide the Route config.
+See the :ref:`sd-dynamic-routing` docs if this sounds interesting to you.
+
+""""
+
+.. [#f1] ":ref:`routing-empty-placeholders`" feature is available starting in version `23.0 `_, see issue `748 `_ and the `23.0 `__ release notes for details.
+.. [#f2] ":ref:`routing-upstream-host`" feature was requested as part of `issue 216 `_.
+.. [#f3] ":ref:`routing-security-options`" feature was requested as part of `issue 628 `_ (of `12.0.1 `_ version), then redesigned and improved by `issue 1400 `_, and published in version `20.0 `_ docs.
+.. [#f4] ":ref:`routing-dynamic`" feature was requested as part of `issue 340 `_. Complete reference: :ref:`sd-dynamic-routing`.
diff --git a/docs/features/servicediscovery.rst b/docs/features/servicediscovery.rst
index 9089240e8..6d9d8d4ef 100644
--- a/docs/features/servicediscovery.rst
+++ b/docs/features/servicediscovery.rst
@@ -188,6 +188,8 @@ When Ocelot asks for a given service it is retrieved from memory so performance
Ocelot will use the scheme (``http``, ``https``) set in Eureka if these values are not provided in **ocelot.json**
+.. _sd-dynamic-routing:
+
Dynamic Routing
---------------
diff --git a/docs/index.rst b/docs/index.rst
index 87d0afa5f..acdb21b46 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,5 +1,5 @@
-Welcome to Ocelot 22.0
-======================
+Welcome to Ocelot `23.0 `_
+======================================================================================
Thanks for taking a look at the Ocelot documentation! Please use the left hand navigation to get around.
The team would suggest taking a look at the **Introduction** chapter first.
diff --git a/docs/introduction/gotchas.rst b/docs/introduction/gotchas.rst
index 9a8f856ae..eec801a61 100644
--- a/docs/introduction/gotchas.rst
+++ b/docs/introduction/gotchas.rst
@@ -1,12 +1,15 @@
Gotchas
=============
-
+
+Many errors and incidents (gotchas) are related to web server hosting scenarios.
+Please review deployment and web hosting common user scenarios below depending on your web server.
+
IIS
------
+---
Microsoft Learn: `Host ASP.NET Core on Windows with IIS `_
-We do not recommend to deploy Ocelot app to IIS environments, but if you do, keep in mind the gotchas below.
+We **do not** recommend to deploy Ocelot app to IIS environments, but if you do, keep in mind the gotchas below.
* When using ASP.NET Core 2.2+ and you want to use In-Process hosting, replace ``UseIISIntegration()`` with ``UseIIS()``, otherwise you will get startup errors.
@@ -23,3 +26,41 @@ Probably you will find a ready solution by Ocelot community members.
Finally, we have special label |IIS| for all IIS related objects. Feel free to put this label onto issues, PRs, discussions, etc.
.. |IIS| image:: https://img.shields.io/badge/-IIS-c5def5.svg
+
+Kestrel
+-------
+
+ Microsoft Learn: `Kestrel web server in ASP.NET Core `_
+
+We **do** recommend to deploy Ocelot app to self-hosting environments, aka Kestrel vs Docker.
+We try to optimize Ocelot web app for Kestrel & Docker hosting scenarios, but keep in mind the following gotchas.
+
+* **Upload and download large files** [#f1]_, proxying the content through the gateway. It is strange when you pump large (static) files using the gateway.
+ We believe that your client apps should have direct integration to (static) files persistent storages and services: remote & destributed file systems, CDNs, static files & blob storages, etc.
+ We **do not** recommend to pump large files (100Mb+ or even larger 1GB+) using gateway because of performance reasons: consuming memory and CPU, long delay times, producing network errors for downstream streaming, impact on other routes.
+
+ | The community constanly reports issues related to `large files `_, ``application/octet-stream`` content type, :ref:`chunked-encoding`, etc., see issues `749 `_, `1472 `_.
+ | If you still want to pump large files through an Ocelot gateway instance, use `23.0 `_ version and higher [#f1]_.
+ | In case of some errors, see the next point.
+
+* **Maximum request body size**. ASP.NET ``HttpRequest`` behaves erroneously for application instances that do not have their Kestrel `MaxRequestBodySize `_ option configured correctly and having pumped large files of unpredictable size which exceeds the limit.
+
+ | Please review these docs: `Maximum request body size | Configure options for the ASP.NET Core Kestrel web server `_.
+
+ | As a quick fix, use this configuration recipe:
+
+ .. code-block:: csharp
+
+ builder.WebHost.ConfigureKestrel((context, serverOptions) =>
+ {
+ int myVideoFileMaxSize = 1_073_741_824; // assume your file storage has max file size as 1 GB (1_073_741_824)
+ int totalSize = myVideoFileMaxSize + 26_258_176; // and add some extra size
+ serverOptions.Limits.MaxRequestBodySize = totalSize; // 1_100_000_000 thus 1 GB file should not exceed the limit
+ });
+
+ Hope it helps.
+
+
+""""
+
+.. [#f1] Large files pumping is stabilized and available as complete solution starting in `23.0 `__ release. We believe our PRs `1724 `_, `1769 `_ helped to resolve the issues and stabilize large content proxying problems of `22.0.1 `_ version and lower.
diff --git a/docs/introduction/notsupported.rst b/docs/introduction/notsupported.rst
index 7c7c9a5f0..58eff06a1 100644
--- a/docs/introduction/notsupported.rst
+++ b/docs/introduction/notsupported.rst
@@ -2,7 +2,9 @@ Not Supported
=============
Ocelot does not support...
-
+
+.. _chunked-encoding:
+
Chunked Encoding
----------------
diff --git a/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs b/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs
index e89265622..eb84b5250 100644
--- a/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs
+++ b/src/Ocelot.Cache.CacheManager/OcelotBuilderExtensions.cs
@@ -34,7 +34,7 @@ public static IOcelotBuilder AddCacheManager(this IOcelotBuilder builder, Action
builder.Services.AddSingleton>(fileConfigCacheManager);
builder.Services.RemoveAll(typeof(ICacheKeyGenerator));
- builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
return builder;
}
diff --git a/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs b/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs
index bf1fc815c..22f58e538 100644
--- a/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs
+++ b/src/Ocelot.Provider.Kubernetes/EndPointClientV1.cs
@@ -4,7 +4,7 @@
namespace Ocelot.Provider.Kubernetes
{
- public class EndPointClientV1 : KubeResourceClient
+ public class EndPointClientV1 : KubeResourceClient, IEndPointClient
{
private readonly HttpRequest _collection;
@@ -13,7 +13,7 @@ public EndPointClientV1(IKubeApiClient client) : base(client)
_collection = KubeRequest.Create("api/v1/namespaces/{Namespace}/endpoints/{ServiceName}");
}
- public async Task Get(string serviceName, string kubeNamespace = null, CancellationToken cancellationToken = default)
+ public async Task GetAsync(string serviceName, string kubeNamespace = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(serviceName))
{
diff --git a/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs b/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs
new file mode 100644
index 000000000..6dfca972d
--- /dev/null
+++ b/src/Ocelot.Provider.Kubernetes/IEndPointClient.cs
@@ -0,0 +1,9 @@
+using KubeClient.Models;
+using KubeClient.ResourceClients;
+
+namespace Ocelot.Provider.Kubernetes;
+
+public interface IEndPointClient : IKubeResourceClient
+{
+ Task GetAsync(string serviceName, string kubeNamespace = null, CancellationToken cancellationToken = default);
+}
diff --git a/src/Ocelot.Provider.Kubernetes/KubernetesServiceDiscoveryProvider.cs b/src/Ocelot.Provider.Kubernetes/Kube.cs
similarity index 78%
rename from src/Ocelot.Provider.Kubernetes/KubernetesServiceDiscoveryProvider.cs
rename to src/Ocelot.Provider.Kubernetes/Kube.cs
index 92c7b99cb..15b5cf6cc 100644
--- a/src/Ocelot.Provider.Kubernetes/KubernetesServiceDiscoveryProvider.cs
+++ b/src/Ocelot.Provider.Kubernetes/Kube.cs
@@ -4,16 +4,19 @@
namespace Ocelot.Provider.Kubernetes;
-public class KubernetesServiceDiscoveryProvider : IServiceDiscoveryProvider
+///
+/// Default Kubernetes service discovery provider.
+///
+public class Kube : IServiceDiscoveryProvider
{
private readonly KubeRegistryConfiguration _kubeRegistryConfiguration;
private readonly IOcelotLogger _logger;
private readonly IKubeApiClient _kubeApi;
- public KubernetesServiceDiscoveryProvider(KubeRegistryConfiguration kubeRegistryConfiguration, IOcelotLoggerFactory factory, IKubeApiClient kubeApi)
+ public Kube(KubeRegistryConfiguration kubeRegistryConfiguration, IOcelotLoggerFactory factory, IKubeApiClient kubeApi)
{
_kubeRegistryConfiguration = kubeRegistryConfiguration;
- _logger = factory.CreateLogger();
+ _logger = factory.CreateLogger();
_kubeApi = kubeApi;
}
@@ -21,7 +24,7 @@ public async Task> GetAsync()
{
var endpoint = await _kubeApi
.ResourceClient(client => new EndPointClientV1(client))
- .Get(_kubeRegistryConfiguration.KeyOfServiceInK8s, _kubeRegistryConfiguration.KubeNamespace);
+ .GetAsync(_kubeRegistryConfiguration.KeyOfServiceInK8s, _kubeRegistryConfiguration.KubeNamespace);
var services = new List();
if (endpoint != null && endpoint.Subsets.Any())
diff --git a/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs b/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs
index 97d58f5eb..4507c03e6 100644
--- a/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs
+++ b/src/Ocelot.Provider.Kubernetes/KubernetesProviderFactory.cs
@@ -18,17 +18,17 @@ private static IServiceDiscoveryProvider CreateProvider(IServiceProvider provide
var factory = provider.GetService();
var kubeClient = provider.GetService();
- var k8SRegistryConfiguration = new KubeRegistryConfiguration
+ var configuration = new KubeRegistryConfiguration
{
KeyOfServiceInK8s = route.ServiceName,
KubeNamespace = string.IsNullOrEmpty(route.ServiceNamespace) ? config.Namespace : route.ServiceNamespace,
};
- var k8SServiceDiscoveryProvider = new KubernetesServiceDiscoveryProvider(k8SRegistryConfiguration, factory, kubeClient);
+ var defaultK8sProvider = new Kube(configuration, factory, kubeClient);
return PollKube.Equals(config.Type, StringComparison.OrdinalIgnoreCase)
- ? new PollKube(config.PollingInterval, factory, k8SServiceDiscoveryProvider)
- : k8SServiceDiscoveryProvider;
+ ? new PollKube(config.PollingInterval, factory, defaultK8sProvider)
+ : defaultK8sProvider;
}
}
}
diff --git a/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs b/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs
index c37c690d8..a12633683 100644
--- a/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs
+++ b/src/Ocelot.Provider.Polly/OcelotBuilderExtensions.cs
@@ -3,6 +3,7 @@
using Ocelot.Configuration;
using Ocelot.DependencyInjection;
using Ocelot.Errors;
+using Ocelot.Errors.QoS;
using Ocelot.Logging;
using Ocelot.Provider.Polly.Interfaces;
using Ocelot.Requester;
diff --git a/src/Ocelot.Provider.Polly/PollyQoSProvider.cs b/src/Ocelot.Provider.Polly/PollyQoSProvider.cs
index edd460e77..7a9465a22 100644
--- a/src/Ocelot.Provider.Polly/PollyQoSProvider.cs
+++ b/src/Ocelot.Provider.Polly/PollyQoSProvider.cs
@@ -13,6 +13,9 @@ public class PollyQoSProvider : IPollyQoSProvider
private readonly object _lockObject = new();
private readonly IOcelotLogger _logger;
+ //todo: this should be configurable and available as global config parameter in ocelot.json
+ public const int DefaultRequestTimeoutSeconds = 90;
+
private readonly HashSet _serverErrorCodes = new()
{
HttpStatusCode.InternalServerError,
@@ -63,14 +66,20 @@ private PollyPolicyWrapper PollyPolicyWrapperFactory(Downst
.Or()
.CircuitBreakerAsync(route.QosOptions.ExceptionsAllowedBeforeBreaking,
durationOfBreak: TimeSpan.FromMilliseconds(route.QosOptions.DurationOfBreak),
- onBreak: (ex, breakDelay) => _logger.LogError(info + $"Breaking the circuit for {breakDelay.TotalMilliseconds} ms!", ex.Exception),
+ onBreak: (ex, breakDelay) =>
+ _logger.LogError(info + $"Breaking the circuit for {breakDelay.TotalMilliseconds} ms!",
+ ex.Exception),
onReset: () => _logger.LogDebug(info + "Call OK! Closed the circuit again."),
onHalfOpen: () => _logger.LogDebug(info + "Half-open; Next call is a trial."));
}
+ // No default set for polly timeout at the minute.
+ // Since a user could potentially set timeout value = 0, we need to handle this case.
+ // TODO throw an exception if the user sets timeout value = 0 or at least return a warning
+ // TODO the design in DelegatingHandlerHandlerFactory should be reviewed
var timeoutPolicy = Policy
.TimeoutAsync(
- TimeSpan.FromMilliseconds(route.QosOptions.TimeoutValue),
+ TimeSpan.FromMilliseconds(route.QosOptions.TimeoutValue),
TimeoutStrategy.Pessimistic);
return new PollyPolicyWrapper(exceptionsAllowedBeforeBreakingPolicy, timeoutPolicy);
diff --git a/src/Ocelot.Provider.Polly/RequestTimedOutError.cs b/src/Ocelot.Provider.Polly/RequestTimedOutError.cs
deleted file mode 100644
index 662e952ab..000000000
--- a/src/Ocelot.Provider.Polly/RequestTimedOutError.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using Ocelot.Errors;
-
-namespace Ocelot.Provider.Polly
-{
- public class RequestTimedOutError : Error
- {
- public RequestTimedOutError(Exception exception)
- : base($"Timeout making http request, exception: {exception}", OcelotErrorCode.RequestTimedOutError, 503)
- {
- }
- }
-}
diff --git a/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs b/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs
index 843c3dac5..22fa1dca0 100644
--- a/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs
+++ b/src/Ocelot/Authentication/Middleware/AuthenticationMiddleware.cs
@@ -6,12 +6,11 @@
namespace Ocelot.Authentication.Middleware
{
- public class AuthenticationMiddleware : OcelotMiddleware
+ public sealed class AuthenticationMiddleware : OcelotMiddleware
{
private readonly RequestDelegate _next;
- public AuthenticationMiddleware(RequestDelegate next,
- IOcelotLoggerFactory loggerFactory)
+ public AuthenticationMiddleware(RequestDelegate next, IOcelotLoggerFactory loggerFactory)
: base(loggerFactory.CreateLogger())
{
_next = next;
@@ -19,42 +18,81 @@ public AuthenticationMiddleware(RequestDelegate next,
public async Task Invoke(HttpContext httpContext)
{
+ var request = httpContext.Request;
+ var path = httpContext.Request.Path;
var downstreamRoute = httpContext.Items.DownstreamRoute();
- if (httpContext.Request.Method.ToUpper() != "OPTIONS" && IsAuthenticatedRoute(downstreamRoute))
+ // reducing nesting, returning early when no authentication is needed.
+ if (request.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase) || !downstreamRoute.IsAuthenticated)
{
- Logger.LogInformation(() => $"{httpContext.Request.Path} is an authenticated route. {MiddlewareName} checking if client is authenticated");
+ Logger.LogInformation($"No authentication needed for path '{path}'.");
+ await _next(httpContext);
+ return;
+ }
- var result = await httpContext.AuthenticateAsync(downstreamRoute.AuthenticationOptions.AuthenticationProviderKey);
+ Logger.LogInformation(() => $"The path '{path}' is an authenticated route! {MiddlewareName} checking if client is authenticated...");
- httpContext.User = result.Principal;
+ var result = await AuthenticateAsync(httpContext, downstreamRoute);
- if (httpContext.User.Identity.IsAuthenticated)
- {
- Logger.LogInformation(() => $"Client has been authenticated for {httpContext.Request.Path}");
- await _next.Invoke(httpContext);
- }
- else
- {
- var error = new UnauthenticatedError(
- $"Request for authenticated route {httpContext.Request.Path} by {httpContext.User.Identity.Name} was unauthenticated");
+ if (result.Principal?.Identity == null)
+ {
+ SetUnauthenticatedError(httpContext, path, null);
+ return;
+ }
- Logger.LogWarning(() =>$"Client has NOT been authenticated for {httpContext.Request.Path} and pipeline error set. {error}");
+ httpContext.User = result.Principal;
- httpContext.Items.SetError(error);
- }
- }
- else
+ if (httpContext.User.Identity.IsAuthenticated)
{
- Logger.LogInformation(() => $"No authentication needed for {httpContext.Request.Path}");
-
+ Logger.LogInformation(() => $"Client has been authenticated for path '{path}' by '{httpContext.User.Identity.AuthenticationType}' scheme.");
await _next.Invoke(httpContext);
+ return;
}
+
+ SetUnauthenticatedError(httpContext, path, httpContext.User.Identity.Name);
+ }
+
+ private void SetUnauthenticatedError(HttpContext httpContext, string path, string userName)
+ {
+ var error = new UnauthenticatedError($"Request for authenticated route '{path}' {(string.IsNullOrEmpty(userName) ? "was unauthenticated" : $"by '{userName}' was unauthenticated!")}");
+ Logger.LogWarning(() => $"Client has NOT been authenticated for path '{path}' and pipeline error set. {error};");
+ httpContext.Items.SetError(error);
}
- private static bool IsAuthenticatedRoute(DownstreamRoute route)
+ private async Task AuthenticateAsync(HttpContext context, DownstreamRoute route)
{
- return route.IsAuthenticated;
+ var options = route.AuthenticationOptions;
+ if (!string.IsNullOrWhiteSpace(options.AuthenticationProviderKey))
+ {
+ return await context.AuthenticateAsync(options.AuthenticationProviderKey);
+ }
+
+ var providerKeys = options.AuthenticationProviderKeys;
+ if (providerKeys.Length == 0 || providerKeys.All(string.IsNullOrWhiteSpace))
+ {
+ Logger.LogWarning(() => $"Impossible to authenticate client for path '{route.DownstreamPathTemplate}': both {nameof(options.AuthenticationProviderKey)} and {nameof(options.AuthenticationProviderKeys)} are empty but the {nameof(Configuration.AuthenticationOptions)} have defined.");
+ return AuthenticateResult.NoResult();
+ }
+
+ AuthenticateResult result = null;
+ foreach (var scheme in providerKeys.Where(apk => !string.IsNullOrWhiteSpace(apk)))
+ {
+ try
+ {
+ result = await context.AuthenticateAsync(scheme);
+ if (result?.Succeeded == true)
+ {
+ return result;
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.LogWarning(() =>
+ $"Impossible to authenticate client for path '{route.DownstreamPathTemplate}' and {nameof(options.AuthenticationProviderKey)}:{scheme}. Error: {e.Message}.");
+ }
+ }
+
+ return result ?? AuthenticateResult.NoResult();
}
}
}
diff --git a/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareExtensions.cs b/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareExtensions.cs
new file mode 100644
index 000000000..d0715e844
--- /dev/null
+++ b/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareExtensions.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Builder;
+
+namespace Ocelot.Authentication.Middleware;
+
+public static class AuthenticationMiddlewareExtensions
+{
+ public static IApplicationBuilder UseAuthenticationMiddleware(this IApplicationBuilder builder)
+ {
+ return builder.UseMiddleware();
+ }
+}
diff --git a/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareMiddlewareExtensions.cs b/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareMiddlewareExtensions.cs
deleted file mode 100644
index 3adddff2d..000000000
--- a/src/Ocelot/Authentication/Middleware/AuthenticationMiddlewareMiddlewareExtensions.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-
-namespace Ocelot.Authentication.Middleware
-{
- public static class AuthenticationMiddlewareMiddlewareExtensions
- {
- public static IApplicationBuilder UseAuthenticationMiddleware(this IApplicationBuilder builder)
- {
- return builder.UseMiddleware();
- }
- }
-}
diff --git a/src/Ocelot/Cache/CacheKeyGenerator.cs b/src/Ocelot/Cache/CacheKeyGenerator.cs
deleted file mode 100644
index e6ae88213..000000000
--- a/src/Ocelot/Cache/CacheKeyGenerator.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Ocelot.Request.Middleware;
-
-namespace Ocelot.Cache
-{
- public class CacheKeyGenerator : ICacheKeyGenerator
- {
- public string GenerateRequestCacheKey(DownstreamRequest downstreamRequest)
- {
- var downStreamUrlKeyBuilder = new StringBuilder($"{downstreamRequest.Method}-{downstreamRequest.OriginalString}");
- if (downstreamRequest.Content != null)
- {
- var requestContentString = Task.Run(async () => await downstreamRequest.Content.ReadAsStringAsync()).Result;
- downStreamUrlKeyBuilder.Append(requestContentString);
- }
-
- var hashedContent = MD5Helper.GenerateMd5(downStreamUrlKeyBuilder.ToString());
- return hashedContent;
- }
- }
-}
diff --git a/src/Ocelot/Cache/DefaultCacheKeyGenerator.cs b/src/Ocelot/Cache/DefaultCacheKeyGenerator.cs
new file mode 100644
index 000000000..ac79fc5c5
--- /dev/null
+++ b/src/Ocelot/Cache/DefaultCacheKeyGenerator.cs
@@ -0,0 +1,46 @@
+using Ocelot.Configuration;
+using Ocelot.Request.Middleware;
+
+namespace Ocelot.Cache;
+
+public class DefaultCacheKeyGenerator : ICacheKeyGenerator
+{
+ private const char Delimiter = '-';
+
+ public async ValueTask GenerateRequestCacheKey(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute)
+ {
+ var builder = new StringBuilder()
+ .Append(downstreamRequest.Method)
+ .Append(Delimiter)
+ .Append(downstreamRequest.OriginalString);
+
+ var options = downstreamRoute?.CacheOptions ?? new();
+ if (!string.IsNullOrEmpty(options.Header))
+ {
+ var header = downstreamRequest.Headers
+ .FirstOrDefault(r => r.Key.Equals(options.Header, StringComparison.OrdinalIgnoreCase))
+ .Value?.FirstOrDefault();
+
+ if (!string.IsNullOrEmpty(header))
+ {
+ builder.Append(Delimiter)
+ .Append(header);
+ }
+ }
+
+ if (!options.EnableContentHashing || !downstreamRequest.HasContent)
+ {
+ return MD5Helper.GenerateMd5(builder.ToString());
+ }
+
+ var requestContentString = await ReadContentAsync(downstreamRequest);
+ builder.Append(Delimiter)
+ .Append(requestContentString);
+
+ return MD5Helper.GenerateMd5(builder.ToString());
+ }
+
+ private static Task ReadContentAsync(DownstreamRequest downstream) => downstream.HasContent
+ ? downstream?.Request?.Content?.ReadAsStringAsync() ?? Task.FromResult(string.Empty)
+ : Task.FromResult(string.Empty);
+}
diff --git a/src/Ocelot/Cache/ICacheKeyGenerator.cs b/src/Ocelot/Cache/ICacheKeyGenerator.cs
index 32a1f989e..d2ccb0ef5 100644
--- a/src/Ocelot/Cache/ICacheKeyGenerator.cs
+++ b/src/Ocelot/Cache/ICacheKeyGenerator.cs
@@ -1,9 +1,10 @@
-using Ocelot.Request.Middleware;
+using Ocelot.Configuration;
+using Ocelot.Request.Middleware;
namespace Ocelot.Cache
{
public interface ICacheKeyGenerator
{
- string GenerateRequestCacheKey(DownstreamRequest downstreamRequest);
+ ValueTask GenerateRequestCacheKey(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute);
}
}
diff --git a/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs b/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs
index 5e3158b48..750f28285 100644
--- a/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs
+++ b/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs
@@ -14,7 +14,7 @@ public OutputCacheMiddleware(RequestDelegate next,
IOcelotLoggerFactory loggerFactory,
IOcelotCache outputCache,
ICacheKeyGenerator cacheGenerator)
- : base(loggerFactory.CreateLogger())
+ : base(loggerFactory.CreateLogger())
{
_next = next;
_outputCache = outputCache;
@@ -33,21 +33,16 @@ public async Task Invoke(HttpContext httpContext)
var downstreamRequest = httpContext.Items.DownstreamRequest();
var downstreamUrlKey = $"{downstreamRequest.Method}-{downstreamRequest.OriginalString}";
- var downStreamRequestCacheKey = _cacheGenerator.GenerateRequestCacheKey(downstreamRequest);
+ var downStreamRequestCacheKey = await _cacheGenerator.GenerateRequestCacheKey(downstreamRequest, downstreamRoute);
Logger.LogDebug(() => $"Started checking cache for the '{downstreamUrlKey}' key.");
-
var cached = _outputCache.Get(downStreamRequestCacheKey, downstreamRoute.CacheOptions.Region);
-
if (cached != null)
{
Logger.LogDebug(() => $"Cache entry exists for the '{downstreamUrlKey}' key.");
-
var response = CreateHttpResponseMessage(cached);
SetHttpResponseMessageThisRequest(httpContext, response);
-
Logger.LogDebug(() => $"Finished returning of cached response for the '{downstreamUrlKey}' key.");
-
return;
}
@@ -58,24 +53,18 @@ public async Task Invoke(HttpContext httpContext)
if (httpContext.Items.Errors().Count > 0)
{
Logger.LogDebug(() => $"There was a pipeline error for the '{downstreamUrlKey}' key.");
-
return;
}
var downstreamResponse = httpContext.Items.DownstreamResponse();
-
cached = await CreateCachedResponse(downstreamResponse);
_outputCache.Add(downStreamRequestCacheKey, cached, TimeSpan.FromSeconds(downstreamRoute.CacheOptions.TtlSeconds), downstreamRoute.CacheOptions.Region);
-
Logger.LogDebug(() => $"Finished response added to cache for the '{downstreamUrlKey}' key.");
}
- private static void SetHttpResponseMessageThisRequest(HttpContext context,
- DownstreamResponse response)
- {
- context.Items.UpsertDownstreamResponse(response);
- }
+ private static void SetHttpResponseMessageThisRequest(HttpContext context, DownstreamResponse response)
+ => context.Items.UpsertDownstreamResponse(response);
internal DownstreamResponse CreateHttpResponseMessage(CachedResponse cached)
{
@@ -85,7 +74,6 @@ internal DownstreamResponse CreateHttpResponseMessage(CachedResponse cached)
}
var content = new MemoryStream(Convert.FromBase64String(cached.Body));
-
var streamContent = new StreamContent(content);
foreach (var header in cached.ContentHeaders)
@@ -114,7 +102,6 @@ internal async Task CreateCachedResponse(DownstreamResponse resp
}
var contentHeaders = response?.Content?.Headers.ToDictionary(v => v.Key, v => v.Value);
-
var cached = new CachedResponse(statusCode, headers, body, contentHeaders, response.ReasonPhrase);
return cached;
}
diff --git a/src/Ocelot/Configuration/AuthenticationOptions.cs b/src/Ocelot/Configuration/AuthenticationOptions.cs
index c504b1d43..af5cf7273 100644
--- a/src/Ocelot/Configuration/AuthenticationOptions.cs
+++ b/src/Ocelot/Configuration/AuthenticationOptions.cs
@@ -1,14 +1,51 @@
-namespace Ocelot.Configuration
-{
- public class AuthenticationOptions
- {
- public AuthenticationOptions(List allowedScopes, string authenticationProviderKey)
- {
- AllowedScopes = allowedScopes;
- AuthenticationProviderKey = authenticationProviderKey;
- }
-
- public List AllowedScopes { get; }
- public string AuthenticationProviderKey { get; }
- }
+using Ocelot.Configuration.File;
+
+namespace Ocelot.Configuration
+{
+ public sealed class AuthenticationOptions
+ {
+ public AuthenticationOptions(List allowedScopes, string authenticationProviderKey)
+ {
+ AllowedScopes = allowedScopes;
+ AuthenticationProviderKey = authenticationProviderKey;
+ AuthenticationProviderKeys = [];
+ }
+
+ public AuthenticationOptions(FileAuthenticationOptions from)
+ {
+ AllowedScopes = from.AllowedScopes ?? [];
+ AuthenticationProviderKey = from.AuthenticationProviderKey ?? string.Empty;
+ AuthenticationProviderKeys = from.AuthenticationProviderKeys ?? [];
+ }
+
+ public AuthenticationOptions(List allowedScopes, string authenticationProviderKey,
+ string[] authenticationProviderKeys)
+ {
+ AllowedScopes = allowedScopes ?? [];
+ AuthenticationProviderKey = authenticationProviderKey ?? string.Empty;
+ AuthenticationProviderKeys = authenticationProviderKeys ?? [];
+ }
+
+ public List AllowedScopes { get; }
+
+ ///
+ /// Authentication scheme registered in DI services with appropriate authentication provider.
+ ///
+ ///
+ /// A value of the scheme name.
+ ///
+ [Obsolete("Use the " + nameof(AuthenticationProviderKeys) + " property!")]
+ public string AuthenticationProviderKey { get; }
+
+ ///
+ /// Multiple authentication schemes registered in DI services with appropriate authentication providers.
+ ///
+ ///
+ /// The order in the collection matters: first successful authentication result wins.
+ ///
+ ///
+ /// An array of values of the scheme names.
+ ///
+ public string[] AuthenticationProviderKeys { get; }
+ }
}
diff --git a/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs b/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs
index bc682afd4..e911908c7 100644
--- a/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs
+++ b/src/Ocelot/Configuration/Builder/AuthenticationOptionsBuilder.cs
@@ -4,22 +4,30 @@ public class AuthenticationOptionsBuilder
{
private List _allowedScopes = new();
private string _authenticationProviderKey;
+ private string[] _authenticationProviderKeys =[];
public AuthenticationOptionsBuilder WithAllowedScopes(List allowedScopes)
{
_allowedScopes = allowedScopes;
return this;
}
-
+
+ [Obsolete("Use the " + nameof(WithAuthenticationProviderKeys) + " property!")]
public AuthenticationOptionsBuilder WithAuthenticationProviderKey(string authenticationProviderKey)
{
_authenticationProviderKey = authenticationProviderKey;
return this;
}
+ public AuthenticationOptionsBuilder WithAuthenticationProviderKeys(string[] authenticationProviderKeys)
+ {
+ _authenticationProviderKeys = authenticationProviderKeys;
+ return this;
+ }
+
public AuthenticationOptions Build()
{
- return new AuthenticationOptions(_allowedScopes, _authenticationProviderKey);
+ return new AuthenticationOptions(_allowedScopes, _authenticationProviderKey, _authenticationProviderKeys);
}
}
}
diff --git a/src/Ocelot/Configuration/CacheOptions.cs b/src/Ocelot/Configuration/CacheOptions.cs
index d509b38e9..352b501d8 100644
--- a/src/Ocelot/Configuration/CacheOptions.cs
+++ b/src/Ocelot/Configuration/CacheOptions.cs
@@ -1,15 +1,39 @@
-namespace Ocelot.Configuration
+using Ocelot.Request.Middleware;
+
+namespace Ocelot.Configuration
{
public class CacheOptions
- {
- public CacheOptions(int ttlSeconds, string region)
+ {
+ internal CacheOptions() { }
+
+ public CacheOptions(int ttlSeconds, string region, string header)
{
TtlSeconds = ttlSeconds;
- Region = region;
+ Region = region;
+ Header = header;
+ }
+
+ public CacheOptions(int ttlSeconds, string region, string header, bool enableContentHashing)
+ {
+ TtlSeconds = ttlSeconds;
+ Region = region;
+ Header = header;
+ EnableContentHashing = enableContentHashing;
}
- public int TtlSeconds { get; }
-
- public string Region { get; }
+ public int TtlSeconds { get; }
+ public string Region { get; }
+ public string Header { get; }
+
+ ///
+ /// Enables MD5 hash calculation of the of the object.
+ ///
+ ///
+ /// Default value is . No hashing by default.
+ ///
+ ///
+ /// if hashing is enabled, otherwise it is .
+ ///
+ public bool EnableContentHashing { get; }
}
}
diff --git a/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs b/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs
index 275d6d90d..d26d39357 100644
--- a/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs
+++ b/src/Ocelot/Configuration/Creator/AuthenticationOptionsCreator.cs
@@ -4,9 +4,7 @@ namespace Ocelot.Configuration.Creator
{
public class AuthenticationOptionsCreator : IAuthenticationOptionsCreator
{
- public AuthenticationOptions Create(FileRoute route)
- {
- return new AuthenticationOptions(route.AuthenticationOptions.AllowedScopes, route.AuthenticationOptions.AuthenticationProviderKey);
- }
+ public AuthenticationOptions Create(FileRoute route)
+ => new(route?.AuthenticationOptions ?? new());
}
}
diff --git a/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs b/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs
index 133fa7020..e7e963c34 100644
--- a/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs
+++ b/src/Ocelot/Configuration/Creator/HttpHandlerOptionsCreator.cs
@@ -8,6 +8,9 @@ public class HttpHandlerOptionsCreator : IHttpHandlerOptionsCreator
{
private readonly ITracer _tracer;
+ //todo: this should be configurable and available as global config parameter in ocelot.json
+ public const int DefaultPooledConnectionLifetimeSeconds = 120;
+
public HttpHandlerOptionsCreator(IServiceProvider services)
{
_tracer = services.GetService();
@@ -19,9 +22,10 @@ public HttpHandlerOptions Create(FileHttpHandlerOptions options)
//be sure that maxConnectionPerServer is in correct range of values
var maxConnectionPerServer = (options.MaxConnectionsPerServer > 0) ? options.MaxConnectionsPerServer : int.MaxValue;
+ var pooledConnectionLifetime = TimeSpan.FromSeconds(options.PooledConnectionLifetimeSeconds ?? DefaultPooledConnectionLifetimeSeconds);
return new HttpHandlerOptions(options.AllowAutoRedirect,
- options.UseCookieContainer, useTracing, options.UseProxy, maxConnectionPerServer);
+ options.UseCookieContainer, useTracing, options.UseProxy, maxConnectionPerServer, pooledConnectionLifetime);
}
}
}
diff --git a/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs
index 2c2f71315..8e0911e56 100644
--- a/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs
+++ b/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs
@@ -6,30 +6,28 @@ namespace Ocelot.Configuration.Creator
public class RouteOptionsCreator : IRouteOptionsCreator
{
public RouteOptions Create(FileRoute fileRoute)
- {
- var isAuthenticated = IsAuthenticated(fileRoute);
- var isAuthorized = IsAuthorized(fileRoute);
- var isCached = IsCached(fileRoute);
- var enableRateLimiting = IsEnableRateLimiting(fileRoute);
+ {
+ if (fileRoute == null)
+ {
+ return new RouteOptionsBuilder().Build();
+ }
+
+ var authOpts = fileRoute.AuthenticationOptions;
+ var isAuthenticated = authOpts != null
+ && (!string.IsNullOrEmpty(authOpts.AuthenticationProviderKey)
+ || authOpts.AuthenticationProviderKeys?.Any(k => !string.IsNullOrWhiteSpace(k)) == true);
+ var isAuthorized = fileRoute.RouteClaimsRequirement?.Any() == true;
+ var isCached = fileRoute.FileCacheOptions.TtlSeconds > 0;
+ var enableRateLimiting = fileRoute.RateLimitOptions?.EnableRateLimiting == true;
var useServiceDiscovery = !string.IsNullOrEmpty(fileRoute.ServiceName);
- var options = new RouteOptionsBuilder()
+ return new RouteOptionsBuilder()
.WithIsAuthenticated(isAuthenticated)
.WithIsAuthorized(isAuthorized)
.WithIsCached(isCached)
.WithRateLimiting(enableRateLimiting)
.WithUseServiceDiscovery(useServiceDiscovery)
.Build();
-
- return options;
}
-
- private static bool IsEnableRateLimiting(FileRoute fileRoute) => fileRoute.RateLimitOptions?.EnableRateLimiting == true;
-
- private static bool IsAuthenticated(FileRoute fileRoute) => !string.IsNullOrEmpty(fileRoute.AuthenticationOptions?.AuthenticationProviderKey);
-
- private static bool IsAuthorized(FileRoute fileRoute) => fileRoute.RouteClaimsRequirement?.Count > 0;
-
- private static bool IsCached(FileRoute fileRoute) => fileRoute.FileCacheOptions.TtlSeconds > 0;
}
}
diff --git a/src/Ocelot/Configuration/Creator/RoutesCreator.cs b/src/Ocelot/Configuration/Creator/RoutesCreator.cs
index 100c65116..8c1f1de63 100644
--- a/src/Ocelot/Configuration/Creator/RoutesCreator.cs
+++ b/src/Ocelot/Configuration/Creator/RoutesCreator.cs
@@ -122,7 +122,7 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf
.WithClaimsToDownstreamPath(claimsToDownstreamPath)
.WithRequestIdKey(requestIdKey)
.WithIsCached(fileRouteOptions.IsCached)
- .WithCacheOptions(new CacheOptions(fileRoute.FileCacheOptions.TtlSeconds, region))
+ .WithCacheOptions(new CacheOptions(fileRoute.FileCacheOptions.TtlSeconds, region, fileRoute.FileCacheOptions.Header))
.WithDownstreamScheme(fileRoute.DownstreamScheme)
.WithLoadBalancerOptions(lbOptions)
.WithDownstreamAddresses(downstreamAddresses)
diff --git a/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs b/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs
index 342373235..a5d0bcf7b 100644
--- a/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs
+++ b/src/Ocelot/Configuration/Creator/UpstreamTemplatePatternCreator.cs
@@ -1,11 +1,11 @@
using Ocelot.Configuration.File;
-using Ocelot.Values;
+using Ocelot.Values;
namespace Ocelot.Configuration.Creator
{
public class UpstreamTemplatePatternCreator : IUpstreamTemplatePatternCreator
{
- private const string RegExMatchOneOrMoreOfEverything = ".+";
+ public const string RegExMatchZeroOrMoreOfEverything = ".*";
private const string RegExMatchOneOrMoreOfEverythingUntilNextForwardSlash = "[^/]+";
private const string RegExMatchEndString = "$";
private const string RegExIgnoreCase = "(?i)";
@@ -40,7 +40,7 @@ public UpstreamPathTemplate Create(IRoute route)
if (upstreamTemplate.Contains('?'))
{
containsQueryString = true;
- upstreamTemplate = upstreamTemplate.Replace("?", "\\?");
+ upstreamTemplate = upstreamTemplate.Replace("?", "(|\\?)");
}
for (var i = 0; i < placeholders.Count; i++)
@@ -49,7 +49,7 @@ public UpstreamPathTemplate Create(IRoute route)
var indexOfNextForwardSlash = upstreamTemplate.IndexOf("/", indexOfPlaceholder, StringComparison.Ordinal);
if (indexOfNextForwardSlash < indexOfPlaceholder || (containsQueryString && upstreamTemplate.IndexOf('?', StringComparison.Ordinal) < upstreamTemplate.IndexOf(placeholders[i], StringComparison.Ordinal)))
{
- upstreamTemplate = upstreamTemplate.Replace(placeholders[i], RegExMatchOneOrMoreOfEverything);
+ upstreamTemplate = upstreamTemplate.Replace(placeholders[i], RegExMatchZeroOrMoreOfEverything);
}
else
{
@@ -60,6 +60,12 @@ public UpstreamPathTemplate Create(IRoute route)
if (upstreamTemplate == "/")
{
return new UpstreamPathTemplate(RegExForwardSlashOnly, route.Priority, containsQueryString, route.UpstreamPathTemplate);
+ }
+
+ var index = upstreamTemplate.LastIndexOf('/'); // index of last forward slash
+ if (index < (upstreamTemplate.Length - 1) && upstreamTemplate[index + 1] == '.')
+ {
+ upstreamTemplate = upstreamTemplate[..index] + "(?:|/" + upstreamTemplate[++index..] + ")";
}
if (upstreamTemplate.EndsWith("/"))
diff --git a/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs b/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs
index eebce214c..24d9b787d 100644
--- a/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs
+++ b/src/Ocelot/Configuration/File/FileAuthenticationOptions.cs
@@ -1,28 +1,31 @@
-namespace Ocelot.Configuration.File
-{
- public class FileAuthenticationOptions
- {
- public FileAuthenticationOptions()
- {
- AllowedScopes = new List();
- }
+namespace Ocelot.Configuration.File
+{
+ public sealed class FileAuthenticationOptions
+ {
+ public FileAuthenticationOptions()
+ {
+ AllowedScopes = [];
+ AuthenticationProviderKeys = [];
+ }
public FileAuthenticationOptions(FileAuthenticationOptions from)
- {
- AllowedScopes = new(from.AllowedScopes);
- AuthenticationProviderKey = from.AuthenticationProviderKey;
- }
-
- public string AuthenticationProviderKey { get; set; }
- public List AllowedScopes { get; set; }
-
- public override string ToString()
- {
- var sb = new StringBuilder();
- sb.Append($"{nameof(AuthenticationProviderKey)}:{AuthenticationProviderKey},{nameof(AllowedScopes)}:[");
- sb.AppendJoin(',', AllowedScopes);
- sb.Append(']');
- return sb.ToString();
- }
- }
+ {
+ AllowedScopes = [..from.AllowedScopes];
+ AuthenticationProviderKey = from.AuthenticationProviderKey;
+ AuthenticationProviderKeys = from.AuthenticationProviderKeys;
+ }
+
+ public List AllowedScopes { get; set; }
+
+ [Obsolete("Use the " + nameof(AuthenticationProviderKeys) + " property!")]
+ public string AuthenticationProviderKey { get; set; }
+
+ public string[] AuthenticationProviderKeys { get; set; }
+
+ public override string ToString() => new StringBuilder()
+ .Append($"{nameof(AuthenticationProviderKey)}:'{AuthenticationProviderKey}',")
+ .Append($"{nameof(AuthenticationProviderKeys)}:[{string.Join(',', AuthenticationProviderKeys.Select(x => $"'{x}'"))}],")
+ .Append($"{nameof(AllowedScopes)}:[{string.Join(',', AllowedScopes.Select(x => $"'{x}'"))}]")
+ .ToString();
+ }
}
diff --git a/src/Ocelot/Configuration/File/FileCacheOptions.cs b/src/Ocelot/Configuration/File/FileCacheOptions.cs
index 65c481344..a1b1deed5 100644
--- a/src/Ocelot/Configuration/File/FileCacheOptions.cs
+++ b/src/Ocelot/Configuration/File/FileCacheOptions.cs
@@ -13,8 +13,9 @@ public FileCacheOptions(FileCacheOptions from)
Region = from.Region;
TtlSeconds = from.TtlSeconds;
}
-
- public string Region { get; set; }
+
public int TtlSeconds { get; set; }
+ public string Region { get; set; }
+ public string Header { get; set; }
}
}
diff --git a/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs b/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs
index 33e1a15cb..4f7f14408 100644
--- a/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs
+++ b/src/Ocelot/Configuration/File/FileHttpHandlerOptions.cs
@@ -8,6 +8,7 @@ public FileHttpHandlerOptions()
MaxConnectionsPerServer = int.MaxValue;
UseCookieContainer = false;
UseProxy = true;
+ PooledConnectionLifetimeSeconds = null;
}
public FileHttpHandlerOptions(FileHttpHandlerOptions from)
@@ -16,12 +17,14 @@ public FileHttpHandlerOptions(FileHttpHandlerOptions from)
MaxConnectionsPerServer = from.MaxConnectionsPerServer;
UseCookieContainer = from.UseCookieContainer;
UseProxy = from.UseProxy;
+ PooledConnectionLifetimeSeconds = from.PooledConnectionLifetimeSeconds;
}
public bool AllowAutoRedirect { get; set; }
public int MaxConnectionsPerServer { get; set; }
public bool UseCookieContainer { get; set; }
public bool UseProxy { get; set; }
- public bool UseTracing { get; set; }
+ public bool UseTracing { get; set; }
+ public int? PooledConnectionLifetimeSeconds { get; set; }
}
}
diff --git a/src/Ocelot/Configuration/HttpHandlerOptions.cs b/src/Ocelot/Configuration/HttpHandlerOptions.cs
index 6976c3f46..c70cca9b7 100644
--- a/src/Ocelot/Configuration/HttpHandlerOptions.cs
+++ b/src/Ocelot/Configuration/HttpHandlerOptions.cs
@@ -1,26 +1,27 @@
namespace Ocelot.Configuration
{
///
- /// Describes configuration parameters for http handler,
- /// that is created to handle a request to service.
+ /// Describes configuration parameters for http handler, that is created to handle a request to service.
///
public class HttpHandlerOptions
{
- public HttpHandlerOptions(bool allowAutoRedirect, bool useCookieContainer, bool useTracing, bool useProxy, int maxConnectionsPerServer)
- {
- AllowAutoRedirect = allowAutoRedirect;
- UseCookieContainer = useCookieContainer;
- UseTracing = useTracing;
- UseProxy = useProxy;
- MaxConnectionsPerServer = maxConnectionsPerServer;
+ public HttpHandlerOptions(bool allowAutoRedirect, bool useCookieContainer, bool useTracing, bool useProxy,
+ int maxConnectionsPerServer, TimeSpan pooledConnectionLifeTime)
+ {
+ AllowAutoRedirect = allowAutoRedirect;
+ UseCookieContainer = useCookieContainer;
+ UseTracing = useTracing;
+ UseProxy = useProxy;
+ MaxConnectionsPerServer = maxConnectionsPerServer;
+ PooledConnectionLifeTime = pooledConnectionLifeTime;
}
///
/// Specify if auto redirect is enabled.
///
/// AllowAutoRedirect.
- public bool AllowAutoRedirect { get; }
-
+ public bool AllowAutoRedirect { get; }
+
///
/// Specify is handler has to use a cookie container.
///
@@ -31,18 +32,24 @@ public HttpHandlerOptions(bool allowAutoRedirect, bool useCookieContainer, bool
/// Specify is handler has to use a opentracing.
///
/// UseTracing.
- public bool UseTracing { get; }
-
+ public bool UseTracing { get; }
+
///
/// Specify if handler has to use a proxy.
///
/// UseProxy.
- public bool UseProxy { get; }
-
+ public bool UseProxy { get; }
+
///
/// Specify the maximum of concurrent connection to a network endpoint.
///
/// MaxConnectionsPerServer.
public int MaxConnectionsPerServer { get; }
+
+ ///
+ /// Specify the maximum of time a connection can be pooled.
+ ///
+ /// PooledConnectionLifeTime.
+ public TimeSpan PooledConnectionLifeTime { get; }
}
-}
+}
diff --git a/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs b/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs
index bc96296d6..ff0f87fe1 100644
--- a/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs
+++ b/src/Ocelot/Configuration/HttpHandlerOptionsBuilder.cs
@@ -1,4 +1,6 @@
-namespace Ocelot.Configuration
+using Ocelot.Configuration.Creator;
+
+namespace Ocelot.Configuration
{
public class HttpHandlerOptionsBuilder
{
@@ -7,6 +9,7 @@ public class HttpHandlerOptionsBuilder
private bool _useTracing;
private bool _useProxy;
private int _maxConnectionPerServer;
+ private TimeSpan _pooledConnectionLifetime = TimeSpan.FromSeconds(HttpHandlerOptionsCreator.DefaultPooledConnectionLifetimeSeconds);
public HttpHandlerOptionsBuilder WithAllowAutoRedirect(bool input)
{
@@ -38,9 +41,15 @@ public HttpHandlerOptionsBuilder WithUseMaxConnectionPerServer(int maxConnection
return this;
}
+ public HttpHandlerOptionsBuilder WithPooledConnectionLifetimeSeconds(TimeSpan pooledConnectionLifetime)
+ {
+ _pooledConnectionLifetime = pooledConnectionLifetime;
+ return this;
+ }
+
public HttpHandlerOptions Build()
{
- return new HttpHandlerOptions(_allowAutoRedirect, _useCookieContainer, _useTracing, _useProxy, _maxConnectionPerServer);
+ return new HttpHandlerOptions(_allowAutoRedirect, _useCookieContainer, _useTracing, _useProxy, _maxConnectionPerServer, _pooledConnectionLifetime);
}
}
}
diff --git a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs
index 82ed1a9eb..064a4e0c9 100644
--- a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs
+++ b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs
@@ -66,9 +66,9 @@ private bool HaveServiceDiscoveryProviderRegistered(FileRoute route, FileService
private bool HaveServiceDiscoveryProviderRegistered(FileServiceDiscoveryProvider serviceDiscoveryProvider)
{
- return serviceDiscoveryProvider == null ||
- serviceDiscoveryProvider?.Type?.ToLower() == Servicefabric ||
- string.IsNullOrEmpty(serviceDiscoveryProvider.Type) || _serviceDiscoveryFinderDelegates.Any();
+ return serviceDiscoveryProvider == null ||
+ Servicefabric.Equals(serviceDiscoveryProvider.Type, StringComparison.InvariantCultureIgnoreCase) ||
+ string.IsNullOrEmpty(serviceDiscoveryProvider.Type) || _serviceDiscoveryFinderDelegates.Any();
}
public async Task> IsValid(FileConfiguration configuration)
@@ -150,13 +150,10 @@ private static bool IsNotDuplicateIn(FileRoute route,
return !duplicate;
}
- private static bool IsNotDuplicateIn(FileAggregateRoute route,
- IEnumerable aggregateRoutes)
+ private static bool IsNotDuplicateIn(FileAggregateRoute route, IEnumerable aggregateRoutes)
{
- var matchingRoutes = aggregateRoutes
- .Where(r => r.UpstreamPathTemplate == route.UpstreamPathTemplate
- && r.UpstreamHost == route.UpstreamHost);
-
+ var matchingRoutes = aggregateRoutes
+ .Where(r => r.UpstreamPathTemplate == route.UpstreamPathTemplate & r.UpstreamHost == route.UpstreamHost);
return matchingRoutes.Count() <= 1;
}
}
diff --git a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs
index 61bb4e0f1..f383bf75c 100644
--- a/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs
+++ b/src/Ocelot/Configuration/Validator/RouteFluentValidator.cs
@@ -86,18 +86,19 @@ public RouteFluentValidator(IAuthenticationSchemeProvider authenticationSchemePr
});
}
- private async Task IsSupportedAuthenticationProviders(FileAuthenticationOptions authenticationOptions, CancellationToken cancellationToken)
+ private async Task IsSupportedAuthenticationProviders(FileAuthenticationOptions options, CancellationToken cancellationToken)
{
- if (string.IsNullOrEmpty(authenticationOptions.AuthenticationProviderKey))
+ if (string.IsNullOrEmpty(options.AuthenticationProviderKey)
+ && options.AuthenticationProviderKeys.Length == 0)
{
return true;
}
var schemes = await _authenticationSchemeProvider.GetAllSchemesAsync();
-
- var supportedSchemes = schemes.Select(scheme => scheme.Name);
-
- return supportedSchemes.Contains(authenticationOptions.AuthenticationProviderKey);
+ var supportedSchemes = schemes.Select(scheme => scheme.Name).ToList();
+ var primary = options.AuthenticationProviderKey;
+ return !string.IsNullOrEmpty(primary) && supportedSchemes.Contains(primary)
+ || (string.IsNullOrEmpty(primary) && options.AuthenticationProviderKeys.All(supportedSchemes.Contains));
}
private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions)
@@ -107,17 +108,17 @@ private static bool IsValidPeriod(FileRateLimitRule rateLimitOptions)
return false;
}
- var period = rateLimitOptions.Period;
+ var period = rateLimitOptions.Period.Trim();
var secondsRegEx = new Regex("^[0-9]+s");
var minutesRegEx = new Regex("^[0-9]+m");
var hoursRegEx = new Regex("^[0-9]+h");
var daysRegEx = new Regex("^[0-9]+d");
-
+
return secondsRegEx.Match(period).Success
- || minutesRegEx.Match(period).Success
- || hoursRegEx.Match(period).Success
- || daysRegEx.Match(period).Success;
+ || minutesRegEx.Match(period).Success
+ || hoursRegEx.Match(period).Success
+ || daysRegEx.Match(period).Success;
}
}
}
diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs
index 4d198711e..a72ec3cbf 100644
--- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs
+++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs
@@ -107,18 +107,17 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo
Services.AddSingleton();
Services.AddSingleton();
Services.TryAddSingleton();
- Services.TryAddSingleton();
Services.TryAddSingleton();
Services.TryAddSingleton();
Services.TryAddSingleton();
- Services.TryAddSingleton();
Services.TryAddSingleton();
Services.TryAddSingleton();
Services.TryAddSingleton();
Services.TryAddSingleton();
- Services.TryAddSingleton();
+ Services.TryAddSingleton();
Services.TryAddSingleton();
Services.TryAddSingleton, OcelotConfigurationMonitor>();
+ Services.AddOcelotMessageInvokerPool();
// See this for why we register this as singleton:
// http://stackoverflow.com/questions/37371264/invalidoperationexception-unable-to-resolve-service-for-type-microsoft-aspnetc
diff --git a/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs b/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs
index 60ecdbf21..d77f5f4d1 100644
--- a/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs
+++ b/src/Ocelot/DownstreamRouteFinder/UrlMatcher/UrlPathPlaceholderNameAndValueFinder.cs
@@ -17,44 +17,41 @@ public Response> Find(string path, string query, s
for (var counterForTemplate = 0; counterForTemplate < pathTemplate.Length; counterForTemplate++)
{
- if ((path.Length > counterForPath) && CharactersDontMatch(pathTemplate[counterForTemplate], path[counterForPath]) && ContinueScanningUrl(counterForPath, path.Length))
+ if (ContinueScanningUrl(counterForPath, path.Length)
+ && CharactersDontMatch(pathTemplate[counterForTemplate], path[counterForPath])
+ && IsPlaceholder(pathTemplate[counterForTemplate]))
{
- if (IsPlaceholder(pathTemplate[counterForTemplate]))
+ //should_find_multiple_query_string make test pass
+ if (PassedQueryString(pathTemplate, counterForTemplate))
{
- //should_find_multiple_query_string make test pass
- if (PassedQueryString(pathTemplate, counterForTemplate))
- {
- delimiter = '&';
- nextDelimiter = '&';
- }
+ delimiter = '&';
+ nextDelimiter = '&';
+ }
- //should_find_multiple_query_string_and_path makes test pass
- if (NotPassedQueryString(pathTemplate, counterForTemplate) && NoMoreForwardSlash(pathTemplate, counterForTemplate))
- {
- delimiter = '?';
- nextDelimiter = '?';
- }
+ //should_find_multiple_query_string_and_path makes test pass
+ if (NotPassedQueryString(pathTemplate, counterForTemplate) && NoMoreForwardSlash(pathTemplate, counterForTemplate))
+ {
+ delimiter = '?';
+ nextDelimiter = '?';
+ }
- var placeholderName = GetPlaceholderName(pathTemplate, counterForTemplate);
+ var placeholderName = GetPlaceholderName(pathTemplate, counterForTemplate);
- var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath, delimiter);
+ var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath, delimiter);
- placeHolderNameAndValues.Add(new PlaceholderNameAndValue(placeholderName, placeholderValue));
+ placeHolderNameAndValues.Add(new PlaceholderNameAndValue(placeholderName, placeholderValue));
- counterForTemplate = GetNextCounterPosition(pathTemplate, counterForTemplate, '}');
+ counterForTemplate = GetNextCounterPosition(pathTemplate, counterForTemplate, '}');
- counterForPath = GetNextCounterPosition(path, counterForPath, nextDelimiter);
+ counterForPath = GetNextCounterPosition(path, counterForPath, nextDelimiter);
- continue;
- }
-
- return new OkResponse>(placeHolderNameAndValues);
+ continue;
}
- else if (IsCatchAll(path, counterForPath, pathTemplate))
+ else if (IsCatchAll(path, counterForPath, pathTemplate) || IsCatchAllAfterOtherPlaceholders(pathTemplate, counterForTemplate))
{
var endOfPlaceholder = GetNextCounterPosition(pathTemplate, counterForTemplate, '}');
- var placeholderName = GetPlaceholderName(pathTemplate, 1);
+ var placeholderName = GetPlaceholderName(pathTemplate, counterForTemplate + 1);
if (NothingAfterFirstForwardSlash(path))
{
@@ -62,11 +59,13 @@ public Response> Find(string path, string query, s
}
else
{
- var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath + 1, '?');
+ var placeholderValue = GetPlaceholderValue(pathTemplate, query, placeholderName, path, counterForPath, '?');
placeHolderNameAndValues.Add(new PlaceholderNameAndValue(placeholderName, placeholderValue));
}
counterForTemplate = endOfPlaceholder;
+ counterForPath = GetNextCounterPosition(path, counterForPath, '?');
+ continue;
}
counterForPath++;
@@ -97,6 +96,12 @@ private static bool IsCatchAll(string path, int counterForPath, string pathTempl
&& pathTemplate.IndexOf('}') == pathTemplate.Length - 1;
}
+ private static bool IsCatchAllAfterOtherPlaceholders(string pathTemplate, int counterForTemplate)
+ => (pathTemplate[counterForTemplate] == '/' || pathTemplate[counterForTemplate] == '?')
+ && (counterForTemplate < pathTemplate.Length - 1)
+ && (pathTemplate[counterForTemplate + 1] == '{')
+ && NoMoreForwardSlash(pathTemplate, counterForTemplate + 1);
+
private static bool NothingAfterFirstForwardSlash(string path)
{
return path.Length == 1 || path.Length == 0;
@@ -104,6 +109,16 @@ private static bool NothingAfterFirstForwardSlash(string path)
private static string GetPlaceholderValue(string urlPathTemplate, string query, string variableName, string urlPath, int counterForUrl, char delimiter)
{
+ if (counterForUrl >= urlPath.Length)
+ {
+ return string.Empty;
+ }
+
+ if ( urlPath[counterForUrl] == '/')
+ {
+ counterForUrl++;
+ }
+
var positionOfNextSlash = urlPath.IndexOf(delimiter, counterForUrl);
if (positionOfNextSlash == -1 || (urlPathTemplate.Trim(delimiter).EndsWith(variableName) && string.IsNullOrEmpty(query)))
diff --git a/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs b/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs
index f8ae6ed26..a92f6a470 100644
--- a/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs
+++ b/src/Ocelot/DownstreamUrlCreator/Middleware/DownstreamUrlCreatorMiddleware.cs
@@ -1,12 +1,12 @@
-using Microsoft.AspNetCore.Http;
-using Ocelot.Configuration;
-using Ocelot.DownstreamRouteFinder.UrlMatcher;
-using Ocelot.Logging;
-using Ocelot.Middleware;
-using Ocelot.Request.Middleware;
-using Ocelot.Responses;
+using Microsoft.AspNetCore.Http;
+using Ocelot.Configuration;
+using Ocelot.DownstreamRouteFinder.UrlMatcher;
+using Ocelot.Logging;
+using Ocelot.Middleware;
+using Ocelot.Request.Middleware;
+using Ocelot.Responses;
using Ocelot.Values;
-using System.Web;
+using System.Web;
namespace Ocelot.DownstreamUrlCreator.Middleware
{
@@ -15,12 +15,13 @@ public class DownstreamUrlCreatorMiddleware : OcelotMiddleware
private readonly RequestDelegate _next;
private readonly IDownstreamPathPlaceholderReplacer _replacer;
- private const char Ampersand = '&';
- private const char QuestionMark = '?';
- private const char OpeningBrace = '{';
- private const char ClosingBrace = '}';
+ private const char Ampersand = '&';
+ private const char QuestionMark = '?';
+ private const char OpeningBrace = '{';
+ private const char ClosingBrace = '}';
+ protected const char Slash = '/';
- public DownstreamUrlCreatorMiddleware(
+ public DownstreamUrlCreatorMiddleware(
RequestDelegate next,
IOcelotLoggerFactory loggerFactory,
IDownstreamPathPlaceholderReplacer replacer)
@@ -36,6 +37,7 @@ public async Task Invoke(HttpContext httpContext)
var placeholders = httpContext.Items.TemplatePlaceholderNameAndValues();
var response = _replacer.Replace(downstreamRoute.DownstreamPathTemplate.Value, placeholders);
var downstreamRequest = httpContext.Items.DownstreamRequest();
+ var upstreamPath = downstreamRequest.AbsolutePath;
if (response.IsError)
{
@@ -44,10 +46,17 @@ public async Task Invoke(HttpContext httpContext)
httpContext.Items.UpsertErrors(response.Errors);
return;
}
+
+ var dsPath = response.Data.Value;
+ if (dsPath.EndsWith(Slash) && !upstreamPath.EndsWith(Slash))
+ {
+ dsPath = dsPath.TrimEnd(Slash);
+ response = new OkResponse(new DownstreamPath(dsPath));
+ }
if (!string.IsNullOrEmpty(downstreamRoute.DownstreamScheme))
{
- //todo make sure this works, hopefully there is a test ;E
+ // TODO Make sure this works, hopefully there is a test ;E
httpContext.Items.DownstreamRequest().Scheme = downstreamRoute.DownstreamScheme;
}
@@ -57,26 +66,25 @@ public async Task Invoke(HttpContext httpContext)
{
var (path, query) = CreateServiceFabricUri(downstreamRequest, downstreamRoute, placeholders, response);
- //todo check this works again hope there is a test..
+ // TODO Check this works again hope there is a test..
downstreamRequest.AbsolutePath = path;
downstreamRequest.Query = query;
}
else
{
- var dsPath = response.Data;
- if (dsPath.Value.Contains(QuestionMark))
+ if (dsPath.Contains(QuestionMark))
{
downstreamRequest.AbsolutePath = GetPath(dsPath);
var newQuery = GetQueryString(dsPath);
downstreamRequest.Query = string.IsNullOrEmpty(downstreamRequest.Query)
? newQuery
- : MergeQueryStringsWithoutDuplicateValues(downstreamRequest.Query, newQuery, placeholders);
+ : MergeQueryStringsWithoutDuplicateValues(downstreamRequest.Query, newQuery, placeholders);
}
else
{
RemoveQueryStringParametersThatHaveBeenUsedInTemplate(downstreamRequest, placeholders);
- downstreamRequest.AbsolutePath = dsPath.Value;
+ downstreamRequest.AbsolutePath = dsPath;
}
}
@@ -86,25 +94,25 @@ public async Task Invoke(HttpContext httpContext)
}
private static string MergeQueryStringsWithoutDuplicateValues(string queryString, string newQueryString, List placeholders)
- {
+ {
newQueryString = newQueryString.Replace(QuestionMark, Ampersand);
var queries = HttpUtility.ParseQueryString(queryString);
var newQueries = HttpUtility.ParseQueryString(newQueryString);
- var parameters = newQueries.AllKeys
- .Where(key => !string.IsNullOrEmpty(key))
+ var parameters = newQueries.AllKeys
+ .Where(key => !string.IsNullOrEmpty(key))
.ToDictionary(key => key, key => newQueries[key]);
- _ = queries.AllKeys
- .Where(key => !string.IsNullOrEmpty(key) && !parameters.ContainsKey(key))
- .All(key => parameters.TryAdd(key, queries[key]));
-
- // Remove old replaced query parameters
- foreach (var placeholder in placeholders)
- {
- parameters.Remove(placeholder.Name.Trim(OpeningBrace, ClosingBrace));
- }
-
+ _ = queries.AllKeys
+ .Where(key => !string.IsNullOrEmpty(key) && !parameters.ContainsKey(key))
+ .All(key => parameters.TryAdd(key, queries[key]));
+
+ // Remove old replaced query parameters
+ foreach (var placeholder in placeholders)
+ {
+ parameters.Remove(placeholder.Name.Trim(OpeningBrace, ClosingBrace));
+ }
+
var orderedParams = parameters.OrderBy(x => x.Key).Select(x => $"{x.Key}={x.Value}");
return QuestionMark + string.Join(Ampersand, orderedParams);
}
@@ -131,16 +139,16 @@ private static void RemoveQueryStringParametersThatHaveBeenUsedInTemplate(Downst
}
}
- private static string GetPath(DownstreamPath dsPath)
- {
- int length = dsPath.Value.IndexOf(QuestionMark, StringComparison.Ordinal);
- return dsPath.Value[..length];
+ private static string GetPath(string downstreamPath)
+ {
+ int length = downstreamPath.IndexOf(QuestionMark, StringComparison.Ordinal);
+ return downstreamPath[..length];
}
- private static string GetQueryString(DownstreamPath dsPath)
+ private static string GetQueryString(string downstreamPath)
{
- int startIndex = dsPath.Value.IndexOf(QuestionMark, StringComparison.Ordinal);
- return dsPath.Value[startIndex..];
+ int startIndex = downstreamPath.IndexOf(QuestionMark, StringComparison.Ordinal);
+ return downstreamPath[startIndex..];
}
private (string Path, string Query) CreateServiceFabricUri(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute, List templatePlaceholderNameAndValues, Response dsPath)
diff --git a/src/Ocelot/Errors/QoS/RequestTimedOutError.cs b/src/Ocelot/Errors/QoS/RequestTimedOutError.cs
new file mode 100644
index 000000000..122d7af7d
--- /dev/null
+++ b/src/Ocelot/Errors/QoS/RequestTimedOutError.cs
@@ -0,0 +1,11 @@
+using StatusCode = System.Net.HttpStatusCode;
+
+namespace Ocelot.Errors.QoS;
+
+public class RequestTimedOutError : Error
+{
+ public RequestTimedOutError(Exception exception)
+ : base($"Timeout making http request, exception: {exception}", OcelotErrorCode.RequestTimedOutError, (int)StatusCode.ServiceUnavailable)
+ {
+ }
+}
diff --git a/src/Ocelot/Middleware/DownstreamResponse.cs b/src/Ocelot/Middleware/DownstreamResponse.cs
index f8275351e..e4e341ef3 100644
--- a/src/Ocelot/Middleware/DownstreamResponse.cs
+++ b/src/Ocelot/Middleware/DownstreamResponse.cs
@@ -1,21 +1,29 @@
namespace Ocelot.Middleware
{
- public class DownstreamResponse
+ public class DownstreamResponse : IDisposable
{
- public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, List headers, string reasonPhrase)
+ // To detect redundant calls
+ private bool _disposedValue;
+ private readonly HttpResponseMessage _responseMessage;
+
+ public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, List headers,
+ string reasonPhrase)
{
Content = content;
StatusCode = statusCode;
- Headers = headers ?? new List();
+ Headers = headers ?? new();
ReasonPhrase = reasonPhrase;
}
public DownstreamResponse(HttpResponseMessage response)
- : this(response.Content, response.StatusCode, response.Headers.Select(x => new Header(x.Key, x.Value)).ToList(), response.ReasonPhrase)
+ : this(response.Content, response.StatusCode,
+ response.Headers.Select(x => new Header(x.Key, x.Value)).ToList(), response.ReasonPhrase)
{
+ _responseMessage = response;
}
- public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, IEnumerable>> headers, string reasonPhrase)
+ public DownstreamResponse(HttpContent content, HttpStatusCode statusCode,
+ IEnumerable>> headers, string reasonPhrase)
: this(content, statusCode, headers.Select(x => new Header(x.Key, x.Value)).ToList(), reasonPhrase)
{
}
@@ -24,5 +32,31 @@ public DownstreamResponse(HttpContent content, HttpStatusCode statusCode, IEnume
public HttpStatusCode StatusCode { get; }
public List Headers { get; }
public string ReasonPhrase { get; }
+
+ // Public implementation of Dispose pattern callable by consumers.
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// We should make sure we dispose the content and response message to close the connection to the downstream service.
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposedValue)
+ {
+ return;
+ }
+
+ if (disposing)
+ {
+ Content?.Dispose();
+ _responseMessage?.Dispose();
+ }
+
+ _disposedValue = true;
+ }
}
}
diff --git a/src/Ocelot/Request/Mapper/IRequestMapper.cs b/src/Ocelot/Request/Mapper/IRequestMapper.cs
index e040d0c5c..901d69ade 100644
--- a/src/Ocelot/Request/Mapper/IRequestMapper.cs
+++ b/src/Ocelot/Request/Mapper/IRequestMapper.cs
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Http;
using Ocelot.Configuration;
-using Ocelot.Responses;
-
-namespace Ocelot.Request.Mapper
-{
- public interface IRequestMapper
- {
- Task> Map(HttpRequest request, DownstreamRoute downstreamRoute);
- }
+
+namespace Ocelot.Request.Mapper;
+
+public interface IRequestMapper
+{
+ HttpRequestMessage Map(HttpRequest request, DownstreamRoute downstreamRoute);
}
diff --git a/src/Ocelot/Request/Mapper/RequestMapper.cs b/src/Ocelot/Request/Mapper/RequestMapper.cs
index a126b48db..8bdde02bb 100644
--- a/src/Ocelot/Request/Mapper/RequestMapper.cs
+++ b/src/Ocelot/Request/Mapper/RequestMapper.cs
@@ -2,109 +2,86 @@
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Primitives;
using Ocelot.Configuration;
-using Ocelot.Responses;
-
-namespace Ocelot.Request.Mapper
-{
- public class RequestMapper : IRequestMapper
- {
- private readonly string[] _unsupportedHeaders = { "host" };
-
- public async Task> Map(HttpRequest request, DownstreamRoute downstreamRoute)
- {
- try
- {
- var requestMessage = new HttpRequestMessage
- {
- Content = await MapContent(request),
- Method = MapMethod(request, downstreamRoute),
- RequestUri = MapUri(request),
- Version = downstreamRoute.DownstreamHttpVersion,
- };
-
- MapHeaders(request, requestMessage);
-
- return new OkResponse(requestMessage);
- }
- catch (Exception ex)
- {
- return new ErrorResponse(new UnmappableRequestError(ex));
- }
- }
-
- private static async Task MapContent(HttpRequest request)
- {
- if (request.Body == null || (request.Body.CanSeek && request.Body.Length <= 0))
- {
- return null;
- }
-
- // Never change this to StreamContent again, I forgot it doesnt work in #464.
- var content = new ByteArrayContent(await ToByteArray(request.Body));
-
- if (!string.IsNullOrEmpty(request.ContentType))
- {
- content.Headers
- .TryAddWithoutValidation("Content-Type", new[] { request.ContentType });
- }
-
- AddHeaderIfExistsOnRequest("Content-Language", content, request);
- AddHeaderIfExistsOnRequest("Content-Location", content, request);
- AddHeaderIfExistsOnRequest("Content-Range", content, request);
- AddHeaderIfExistsOnRequest("Content-MD5", content, request);
- AddHeaderIfExistsOnRequest("Content-Disposition", content, request);
- AddHeaderIfExistsOnRequest("Content-Encoding", content, request);
-
- return content;
- }
-
- private static void AddHeaderIfExistsOnRequest(string key, HttpContent content, HttpRequest request)
- {
- if (request.Headers.ContainsKey(key))
- {
- content.Headers
- .TryAddWithoutValidation(key, request.Headers[key].ToArray());
- }
- }
-
- private static HttpMethod MapMethod(HttpRequest request, DownstreamRoute downstreamRoute)
- {
- if (!string.IsNullOrEmpty(downstreamRoute?.DownstreamHttpMethod))
- {
- return new HttpMethod(downstreamRoute.DownstreamHttpMethod);
- }
-
- return new HttpMethod(request.Method);
- }
-
- private static Uri MapUri(HttpRequest request) => new(request.GetEncodedUrl());
-
- private void MapHeaders(HttpRequest request, HttpRequestMessage requestMessage)
- {
- foreach (var header in request.Headers)
- {
- if (IsSupportedHeader(header))
- {
- requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
- }
- }
- }
-
- private bool IsSupportedHeader(KeyValuePair header)
- {
- return !_unsupportedHeaders.Contains(header.Key.ToLower());
- }
-
- private static async Task ToByteArray(Stream stream)
- {
- await using (stream)
- {
- using (var memStream = new MemoryStream())
- {
- await stream.CopyToAsync(memStream);
- return memStream.ToArray();
- }
- }
- }
- }
+
+namespace Ocelot.Request.Mapper;
+
+public class RequestMapper : IRequestMapper
+{
+ private static readonly HashSet UnsupportedHeaders = new(StringComparer.OrdinalIgnoreCase) { "host" };
+ private static readonly string[] ContentHeaders = { "Content-Length", "Content-Language", "Content-Location", "Content-Range", "Content-MD5", "Content-Disposition", "Content-Encoding" };
+
+ public HttpRequestMessage Map(HttpRequest request, DownstreamRoute downstreamRoute)
+ {
+ var requestMessage = new HttpRequestMessage
+ {
+ Content = MapContent(request),
+ Method = MapMethod(request, downstreamRoute),
+ RequestUri = MapUri(request),
+ Version = downstreamRoute.DownstreamHttpVersion,
+ };
+
+ MapHeaders(request, requestMessage);
+
+ return requestMessage;
+ }
+
+ private static HttpContent MapContent(HttpRequest request)
+ {
+ // TODO We should check if we really need to call HttpRequest.Body.Length
+ // But we assume that if CanSeek is true, the length is calculated without an important overhead
+ if (request.Body is null or { CanSeek: true, Length: <= 0 })
+ {
+ return null;
+ }
+
+ var content = new StreamHttpContent(request.HttpContext);
+
+ AddContentHeaders(request, content);
+
+ return content;
+ }
+
+ private static void AddContentHeaders(HttpRequest request, HttpContent content)
+ {
+ if (!string.IsNullOrEmpty(request.ContentType))
+ {
+ content.Headers
+ .TryAddWithoutValidation("Content-Type", new[] { request.ContentType });
+ }
+
+ // The performance might be improved by retrieving the matching headers from the request
+ // instead of calling request.Headers.TryGetValue for each used content header
+ var matchingHeaders = ContentHeaders.Where(header => request.Headers.ContainsKey(header));
+
+ foreach (var key in matchingHeaders)
+ {
+ if (!request.Headers.TryGetValue(key, out var value))
+ {
+ continue;
+ }
+
+ content.Headers.TryAddWithoutValidation(key, value.ToArray());
+ }
+ }
+
+ private static HttpMethod MapMethod(HttpRequest request, DownstreamRoute downstreamRoute) =>
+ !string.IsNullOrEmpty(downstreamRoute?.DownstreamHttpMethod) ?
+ new HttpMethod(downstreamRoute.DownstreamHttpMethod) : new HttpMethod(request.Method);
+
+ // TODO Review this method, request.GetEncodedUrl() could throw a NullReferenceException
+ private static Uri MapUri(HttpRequest request) => new(request.GetEncodedUrl());
+
+ private static void MapHeaders(HttpRequest request, HttpRequestMessage requestMessage)
+ {
+ foreach (var header in request.Headers)
+ {
+ if (IsSupportedHeader(header))
+ {
+ requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
+ }
+ }
+ }
+
+ private static bool IsSupportedHeader(KeyValuePair header) =>
+ !UnsupportedHeaders.Contains(header.Key);
}
diff --git a/src/Ocelot/Request/Mapper/StreamHttpContent.cs b/src/Ocelot/Request/Mapper/StreamHttpContent.cs
new file mode 100644
index 000000000..0e8294db7
--- /dev/null
+++ b/src/Ocelot/Request/Mapper/StreamHttpContent.cs
@@ -0,0 +1,113 @@
+using Microsoft.AspNetCore.Http;
+using System.Buffers;
+
+namespace Ocelot.Request.Mapper;
+
+public class StreamHttpContent : HttpContent
+{
+ private const int DefaultBufferSize = 65536;
+ public const long UnknownLength = -1;
+ private readonly HttpContext _context;
+
+ public StreamHttpContent(HttpContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context,
+ CancellationToken cancellationToken)
+ => await CopyAsync(_context.Request.Body, stream, Headers.ContentLength ?? UnknownLength, false,
+ cancellationToken);
+
+ protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context)
+ => await CopyAsync(_context.Request.Body, stream, Headers.ContentLength ?? UnknownLength, false,
+ CancellationToken.None);
+
+ protected override bool TryComputeLength(out long length)
+ {
+ length = -1;
+ return false;
+ }
+
+ // This is used internally by HttpContent.ReadAsStreamAsync(...)
+ protected override Task CreateContentReadStreamAsync()
+ {
+ // Nobody should be calling this...
+ throw new NotImplementedException();
+ }
+
+ private static async Task CopyAsync(Stream input, Stream output, long announcedContentLength,
+ bool autoFlush, CancellationToken cancellation)
+ {
+ // For smaller payloads, avoid allocating a buffer that is larger than the announced content length
+ var minBufferSize = announcedContentLength != UnknownLength && announcedContentLength < DefaultBufferSize
+ ? (int)announcedContentLength
+ : DefaultBufferSize;
+
+ var buffer = ArrayPool.Shared.Rent(minBufferSize);
+ long contentLength = 0;
+ try
+ {
+ while (true)
+ {
+ // Issue a zero-byte read to the input stream to defer buffer allocation until data is available.
+ // Note that if the underlying stream does not supporting blocking on zero byte reads, then this will
+ // complete immediately and won't save any memory, but will still function correctly.
+ var zeroByteReadTask = input.ReadAsync(Memory.Empty, cancellation);
+ if (zeroByteReadTask.IsCompletedSuccessfully)
+ {
+ // Consume the ValueTask's result in case it is backed by an IValueTaskSource
+ _ = zeroByteReadTask.Result;
+ }
+ else
+ {
+ // Take care not to return the same buffer to the pool twice in case zeroByteReadTask throws
+ var bufferToReturn = buffer;
+ buffer = null;
+ ArrayPool.Shared.Return(bufferToReturn);
+
+ await zeroByteReadTask;
+
+ buffer = ArrayPool.Shared.Rent(minBufferSize);
+ }
+
+ var read = await input.ReadAsync(buffer.AsMemory(), cancellation);
+ contentLength += read;
+
+ // Normally this is enforced by the server, but it could get out of sync if something in the proxy modified the body.
+ if (announcedContentLength != UnknownLength && contentLength > announcedContentLength)
+ {
+ throw new InvalidOperationException($"More data ({contentLength} bytes) received than the specified Content-Length of {announcedContentLength} bytes.");
+ }
+
+ // End of the source stream.
+ if (read == 0)
+ {
+ if (announcedContentLength == UnknownLength || contentLength == announcedContentLength)
+ {
+ return;
+ }
+ else
+ {
+ throw new InvalidOperationException($"Sent {contentLength} request content bytes, but Content-Length promised {announcedContentLength}.");
+ }
+ }
+
+ await output.WriteAsync(buffer.AsMemory(0, read), cancellation);
+ if (autoFlush)
+ {
+ // HttpClient doesn't always flush outgoing data unless the buffer is full or the caller asks.
+ // This is a problem for streaming protocols like WebSockets and gRPC.
+ await output.FlushAsync(cancellation);
+ }
+ }
+ }
+ finally
+ {
+ if (buffer != null)
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+ }
+}
diff --git a/src/Ocelot/Request/Middleware/DownstreamRequest.cs b/src/Ocelot/Request/Middleware/DownstreamRequest.cs
index 18b8641e1..045720c37 100644
--- a/src/Ocelot/Request/Middleware/DownstreamRequest.cs
+++ b/src/Ocelot/Request/Middleware/DownstreamRequest.cs
@@ -6,6 +6,8 @@ public class DownstreamRequest
{
private readonly HttpRequestMessage _request;
+ public DownstreamRequest() { }
+
public DownstreamRequest(HttpRequestMessage request)
{
_request = request;
@@ -14,13 +16,11 @@ public DownstreamRequest(HttpRequestMessage request)
Scheme = _request.RequestUri.Scheme;
Host = _request.RequestUri.Host;
Port = _request.RequestUri.Port;
- Headers = _request.Headers;
AbsolutePath = _request.RequestUri.AbsolutePath;
Query = _request.RequestUri.Query;
- Content = _request.Content;
}
- public HttpRequestHeaders Headers { get; }
+ public HttpHeaders Headers { get => _request.Headers; }
public string Method { get; }
@@ -36,7 +36,9 @@ public DownstreamRequest(HttpRequestMessage request)
public string Query { get; set; }
- public HttpContent Content { get; set; }
+ public bool HasContent { get => _request?.Content != null; }
+
+ public HttpRequestMessage Request { get => _request; }
public HttpRequestMessage ToHttpRequestMessage()
{
diff --git a/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs b/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs
index a765f3997..6440e3d46 100644
--- a/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs
+++ b/src/Ocelot/Request/Middleware/DownstreamRequestInitialiserMiddleware.cs
@@ -2,43 +2,46 @@
using Ocelot.Logging;
using Ocelot.Middleware;
using Ocelot.Request.Creator;
+using Ocelot.Request.Mapper;
-namespace Ocelot.Request.Middleware
+namespace Ocelot.Request.Middleware;
+
+public class DownstreamRequestInitialiserMiddleware : OcelotMiddleware
{
- public class DownstreamRequestInitialiserMiddleware : OcelotMiddleware
+ private readonly RequestDelegate _next;
+ private readonly IRequestMapper _requestMapper;
+ private readonly IDownstreamRequestCreator _creator;
+
+ public DownstreamRequestInitialiserMiddleware(RequestDelegate next,
+ IOcelotLoggerFactory loggerFactory,
+ IRequestMapper requestMapper,
+ IDownstreamRequestCreator creator)
+ : base(loggerFactory.CreateLogger())
{
- private readonly RequestDelegate _next;
- private readonly Mapper.IRequestMapper _requestMapper;
- private readonly IDownstreamRequestCreator _creator;
+ _next = next;
+ _requestMapper = requestMapper;
+ _creator = creator;
+ }
- public DownstreamRequestInitialiserMiddleware(RequestDelegate next,
- IOcelotLoggerFactory loggerFactory,
- Mapper.IRequestMapper requestMapper,
- IDownstreamRequestCreator creator)
- : base(loggerFactory.CreateLogger())
+ public async Task Invoke(HttpContext httpContext)
+ {
+ var downstreamRoute = httpContext.Items.DownstreamRoute();
+ HttpRequestMessage httpRequestMessage;
+
+ try
{
- _next = next;
- _requestMapper = requestMapper;
- _creator = creator;
+ httpRequestMessage = _requestMapper.Map(httpContext.Request, downstreamRoute);
}
-
- public async Task Invoke(HttpContext httpContext)
+ catch (Exception ex)
{
- var downstreamRoute = httpContext.Items.DownstreamRoute();
-
- var httpRequestMessage = await _requestMapper.Map(httpContext.Request, downstreamRoute);
-
- if (httpRequestMessage.IsError)
- {
- httpContext.Items.UpsertErrors(httpRequestMessage.Errors);
- return;
- }
-
- var downstreamRequest = _creator.Create(httpRequestMessage.Data);
+ // TODO Review the error handling, we should throw an exception here and use the global error handler middleware to catch it
+ httpContext.Items.UpsertErrors([new UnmappableRequestError(ex)]);
+ return;
+ }
- httpContext.Items.UpsertDownstreamRequest(downstreamRequest);
+ var downstreamRequest = _creator.Create(httpRequestMessage);
+ httpContext.Items.UpsertDownstreamRequest(downstreamRequest);
- await _next.Invoke(httpContext);
- }
+ await _next.Invoke(httpContext);
}
}
diff --git a/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs b/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs
index 5fef5a918..65cad3252 100644
--- a/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs
+++ b/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs
@@ -63,14 +63,14 @@ private void SetOcelotRequestId(HttpContext httpContext)
}
}
- private static bool ShouldAddRequestId(RequestId requestId, HttpRequestHeaders headers)
+ private static bool ShouldAddRequestId(RequestId requestId, HttpHeaders headers)
{
return !string.IsNullOrEmpty(requestId?.RequestIdKey)
&& !string.IsNullOrEmpty(requestId.RequestIdValue)
&& !RequestIdInHeaders(requestId, headers);
}
- private static bool RequestIdInHeaders(RequestId requestId, HttpRequestHeaders headers)
+ private static bool RequestIdInHeaders(RequestId requestId, HttpHeaders headers)
{
return headers.TryGetValues(requestId.RequestIdKey, out var value);
}
diff --git a/src/Ocelot/Requester/HttpClientBuilder.cs b/src/Ocelot/Requester/HttpClientBuilder.cs
deleted file mode 100644
index 99b2bec3e..000000000
--- a/src/Ocelot/Requester/HttpClientBuilder.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-using Ocelot.Configuration;
-using Ocelot.Logging;
-
-namespace Ocelot.Requester
-{
- public class HttpClientBuilder : IHttpClientBuilder
- {
- private readonly IDelegatingHandlerHandlerFactory _factory;
- private readonly IHttpClientCache _cacheHandlers;
- private readonly IOcelotLogger _logger;
- private DownstreamRoute _cacheKey;
- private HttpClient _httpClient;
- private IHttpClient _client;
- private readonly TimeSpan _defaultTimeout;
-
- public HttpClientBuilder(
- IDelegatingHandlerHandlerFactory factory,
- IHttpClientCache cacheHandlers,
- IOcelotLogger logger)
- {
- _factory = factory;
- _cacheHandlers = cacheHandlers;
- _logger = logger;
-
- // This is hardcoded at the moment but can easily be added to configuration
- // if required by a user request.
- _defaultTimeout = TimeSpan.FromSeconds(90);
- }
-
- public IHttpClient Create(DownstreamRoute downstreamRoute)
- {
- _cacheKey = downstreamRoute;
-
- var httpClient = _cacheHandlers.Get(_cacheKey);
-
- if (httpClient != null)
- {
- _client = httpClient;
- return httpClient;
- }
-
- var handler = CreateHandler(downstreamRoute);
-
- if (downstreamRoute.DangerousAcceptAnyServerCertificateValidator)
- {
- handler.ServerCertificateCustomValidationCallback =
- HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
-
- _logger
- .LogWarning(() => $"You have ignored all SSL warnings by using DangerousAcceptAnyServerCertificateValidator for this DownstreamRoute, UpstreamPathTemplate: {downstreamRoute.UpstreamPathTemplate}, DownstreamPathTemplate: {downstreamRoute.DownstreamPathTemplate}");
- }
-
- var timeout = downstreamRoute.QosOptions.TimeoutValue == 0
- ? _defaultTimeout
- : TimeSpan.FromMilliseconds(downstreamRoute.QosOptions.TimeoutValue);
-
- _httpClient = new HttpClient(CreateHttpMessageHandler(handler, downstreamRoute))
- {
- Timeout = timeout,
- };
-
- _client = new HttpClientWrapper(_httpClient);
-
- return _client;
- }
-
- private static HttpClientHandler CreateHandler(DownstreamRoute downstreamRoute)
- {
- // Dont' create the CookieContainer if UseCookies is not set or the HttpClient will complain
- // under .Net Full Framework
- var useCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer;
-
- return useCookies ? UseCookiesHandler(downstreamRoute) : UseNonCookiesHandler(downstreamRoute);
- }
-
- private static HttpClientHandler UseNonCookiesHandler(DownstreamRoute downstreamRoute)
- {
- return new HttpClientHandler
- {
- AllowAutoRedirect = downstreamRoute.HttpHandlerOptions.AllowAutoRedirect,
- UseCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer,
- UseProxy = downstreamRoute.HttpHandlerOptions.UseProxy,
- MaxConnectionsPerServer = downstreamRoute.HttpHandlerOptions.MaxConnectionsPerServer,
- };
- }
-
- private static HttpClientHandler UseCookiesHandler(DownstreamRoute downstreamRoute)
- {
- return new HttpClientHandler
- {
- AllowAutoRedirect = downstreamRoute.HttpHandlerOptions.AllowAutoRedirect,
- UseCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer,
- UseProxy = downstreamRoute.HttpHandlerOptions.UseProxy,
- MaxConnectionsPerServer = downstreamRoute.HttpHandlerOptions.MaxConnectionsPerServer,
- CookieContainer = new CookieContainer(),
- };
- }
-
- public void Save()
- {
- _cacheHandlers.Set(_cacheKey, _client, TimeSpan.FromHours(24));
- }
-
- private HttpMessageHandler CreateHttpMessageHandler(HttpMessageHandler httpMessageHandler, DownstreamRoute request)
- {
- //todo handle error
- var handlers = _factory.Get(request).Data;
-
- handlers
- .Select(handler => handler)
- .Reverse()
- .ToList()
- .ForEach(handler =>
- {
- var delegatingHandler = handler();
- delegatingHandler.InnerHandler = httpMessageHandler;
- httpMessageHandler = delegatingHandler;
- });
- return httpMessageHandler;
- }
- }
-}
diff --git a/src/Ocelot/Requester/HttpClientHttpRequester.cs b/src/Ocelot/Requester/HttpClientHttpRequester.cs
deleted file mode 100644
index cf3044681..000000000
--- a/src/Ocelot/Requester/HttpClientHttpRequester.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using Microsoft.AspNetCore.Http;
-using Ocelot.Logging;
-using Ocelot.Middleware;
-using Ocelot.Responses;
-
-namespace Ocelot.Requester
-{
- public class HttpClientHttpRequester : IHttpRequester
- {
- private readonly IHttpClientCache _cacheHandlers;
- private readonly IOcelotLogger _logger;
- private readonly IDelegatingHandlerHandlerFactory _factory;
- private readonly IExceptionToErrorMapper _mapper;
-
- public HttpClientHttpRequester(IOcelotLoggerFactory loggerFactory,
- IHttpClientCache cacheHandlers,
- IDelegatingHandlerHandlerFactory factory,
- IExceptionToErrorMapper mapper)
- {
- _logger = loggerFactory.CreateLogger();
- _cacheHandlers = cacheHandlers;
- _factory = factory;
- _mapper = mapper;
- }
-
- public async Task> GetResponse(HttpContext httpContext)
- {
- var builder = new HttpClientBuilder(_factory, _cacheHandlers, _logger);
-
- var downstreamRoute = httpContext.Items.DownstreamRoute();
-
- var downstreamRequest = httpContext.Items.DownstreamRequest();
-
- var httpClient = builder.Create(downstreamRoute);
-
- try
- {
- var response = await httpClient.SendAsync(downstreamRequest.ToHttpRequestMessage(), httpContext.RequestAborted);
- return new OkResponse(response);
- }
- catch (Exception exception)
- {
- var error = _mapper.Map(exception);
- return new ErrorResponse(error);
- }
- finally
- {
- builder.Save();
- }
- }
- }
-}
diff --git a/src/Ocelot/Requester/HttpClientWrapper.cs b/src/Ocelot/Requester/HttpClientWrapper.cs
deleted file mode 100644
index 096f6afa5..000000000
--- a/src/Ocelot/Requester/HttpClientWrapper.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace Ocelot.Requester
-{
- ///
- /// This class was made to make unit testing easier when HttpClient is used.
- ///
- public class HttpClientWrapper : IHttpClient
- {
- public HttpClient Client { get; }
-
- public HttpClientWrapper(HttpClient client)
- {
- Client = client;
- }
-
- public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
- {
- return Client.SendAsync(request, cancellationToken);
- }
- }
-}
diff --git a/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs b/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs
index 62b03cfa9..dad0e856c 100644
--- a/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs
+++ b/src/Ocelot/Requester/HttpExceptionToErrorMapper.cs
@@ -1,10 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using Ocelot.Errors;
+using Ocelot.Errors.QoS;
namespace Ocelot.Requester
{
public class HttpExceptionToErrorMapper : IExceptionToErrorMapper
{
+ /// This is a dictionary of custom mappers for exceptions.
private readonly Dictionary> _mappers;
public HttpExceptionToErrorMapper(IServiceProvider serviceProvider)
@@ -16,17 +18,26 @@ public Error Map(Exception exception)
{
var type = exception.GetType();
+ // If there is a custom mapper for this exception type, use it
+ // The idea is the following: When implementing features or providers,
+ // you can provide a custom mapper
if (_mappers != null && _mappers.TryGetValue(type, out var mapper))
{
return mapper(exception);
}
+ // here are mapped the exceptions thrown from Ocelot core application
+ if (type == typeof(TimeoutException))
+ {
+ return new RequestTimedOutError(exception);
+ }
+
if (type == typeof(OperationCanceledException) || type.IsSubclassOf(typeof(OperationCanceledException)))
{
return new RequestCanceledError(exception.Message);
}
- if (type == typeof(HttpRequestException))
+ if (type == typeof(HttpRequestException) || type == typeof(TimeoutException))
{
return new ConnectionToDownstreamServiceError(exception);
}
diff --git a/src/Ocelot/Requester/IHttpClient.cs b/src/Ocelot/Requester/IHttpClient.cs
deleted file mode 100644
index 3d106def2..000000000
--- a/src/Ocelot/Requester/IHttpClient.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace Ocelot.Requester
-{
- public interface IHttpClient
- {
- HttpClient Client { get; }
-
- Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default);
- }
-}
diff --git a/src/Ocelot/Requester/IHttpClientBuilder.cs b/src/Ocelot/Requester/IHttpClientBuilder.cs
deleted file mode 100644
index b284a69ba..000000000
--- a/src/Ocelot/Requester/IHttpClientBuilder.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Ocelot.Configuration;
-
-namespace Ocelot.Requester
-{
- public interface IHttpClientBuilder
- {
- IHttpClient Create(DownstreamRoute downstreamRoute);
-
- void Save();
- }
-}
diff --git a/src/Ocelot/Requester/IHttpClientCache.cs b/src/Ocelot/Requester/IHttpClientCache.cs
deleted file mode 100644
index eeb3fa7b7..000000000
--- a/src/Ocelot/Requester/IHttpClientCache.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Ocelot.Configuration;
-
-namespace Ocelot.Requester
-{
- public interface IHttpClientCache
- {
- IHttpClient Get(DownstreamRoute key);
-
- void Set(DownstreamRoute key, IHttpClient handler, TimeSpan expirationTime);
- }
-}
diff --git a/src/Ocelot/Requester/IMessageInvokerPool.cs b/src/Ocelot/Requester/IMessageInvokerPool.cs
new file mode 100644
index 000000000..f8d5edf73
--- /dev/null
+++ b/src/Ocelot/Requester/IMessageInvokerPool.cs
@@ -0,0 +1,25 @@
+using Ocelot.Configuration;
+
+namespace Ocelot.Requester;
+
+///
+/// A pool implementation for pooling.
+///
+/// Largely inspired by StackExchange implementation.
+/// Link: StackExchange.Utils.DefaultHttpClientPool.
+///
+///
+public interface IMessageInvokerPool
+{
+ ///
+ /// Gets a client for the specified .
+ ///
+ /// The route to get a Message Invoker for.
+ /// A from the pool.
+ HttpMessageInvoker Get(DownstreamRoute downstreamRoute);
+
+ ///
+ /// Clears the pool, in case you need to.
+ ///
+ void Clear();
+}
diff --git a/src/Ocelot/Requester/MemoryHttpClientCache.cs b/src/Ocelot/Requester/MemoryHttpClientCache.cs
deleted file mode 100644
index 60f90c1a6..000000000
--- a/src/Ocelot/Requester/MemoryHttpClientCache.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using Ocelot.Configuration;
-
-namespace Ocelot.Requester
-{
- public class MemoryHttpClientCache : IHttpClientCache
- {
- private readonly ConcurrentDictionary _httpClientsCache;
-
- public MemoryHttpClientCache()
- {
- _httpClientsCache = new ConcurrentDictionary();
- }
-
- public void Set(DownstreamRoute key, IHttpClient client, TimeSpan expirationTime)
- {
- _httpClientsCache.AddOrUpdate(key, client, (k, oldValue) => client);
- }
-
- public IHttpClient Get(DownstreamRoute key)
- {
- //todo handle error?
- return _httpClientsCache.TryGetValue(key, out var client) ? client : null;
- }
- }
-}
diff --git a/src/Ocelot/Requester/MessageInvokerHttpRequester.cs b/src/Ocelot/Requester/MessageInvokerHttpRequester.cs
new file mode 100644
index 000000000..ea80de76e
--- /dev/null
+++ b/src/Ocelot/Requester/MessageInvokerHttpRequester.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Http;
+using Ocelot.Logging;
+using Ocelot.Middleware;
+using Ocelot.Responses;
+
+namespace Ocelot.Requester;
+
+public class MessageInvokerHttpRequester : IHttpRequester
+{
+ private readonly IOcelotLogger _logger;
+ private readonly IExceptionToErrorMapper _mapper;
+ private readonly IMessageInvokerPool _messageHandlerPool;
+
+ public MessageInvokerHttpRequester(IOcelotLoggerFactory loggerFactory,
+ IMessageInvokerPool messageHandlerPool,
+ IExceptionToErrorMapper mapper)
+ {
+ _logger = loggerFactory.CreateLogger();
+ _messageHandlerPool = messageHandlerPool;
+ _mapper = mapper;
+ }
+
+ public async Task> GetResponse(HttpContext httpContext)
+ {
+ var downstreamRequest = httpContext.Items.DownstreamRequest();
+ var messageInvoker = _messageHandlerPool.Get(httpContext.Items.DownstreamRoute());
+ try
+ {
+ var response = await messageInvoker.SendAsync(downstreamRequest.ToHttpRequestMessage(), httpContext.RequestAborted);
+ return new OkResponse(response);
+ }
+ catch (Exception exception)
+ {
+ var error = _mapper.Map(exception);
+ return new ErrorResponse(error);
+ }
+ }
+}
diff --git a/src/Ocelot/Requester/MessageInvokerPool.cs b/src/Ocelot/Requester/MessageInvokerPool.cs
new file mode 100644
index 000000000..32d9b5235
--- /dev/null
+++ b/src/Ocelot/Requester/MessageInvokerPool.cs
@@ -0,0 +1,110 @@
+using Ocelot.Configuration;
+using Ocelot.Logging;
+using System.Net.Security;
+
+namespace Ocelot.Requester;
+
+public class MessageInvokerPool : IMessageInvokerPool
+{
+ ///
+ /// TODO This should be configurable and available as global config parameter in ocelot.json.
+ ///
+ public const int DefaultRequestTimeoutSeconds = 90;
+
+ private readonly ConcurrentDictionary> _handlersPool;
+ private readonly IDelegatingHandlerHandlerFactory _handlerFactory;
+ private readonly IOcelotLogger _logger;
+
+ public MessageInvokerPool(IDelegatingHandlerHandlerFactory handlerFactory, IOcelotLoggerFactory loggerFactory)
+ {
+ _handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
+ _handlersPool = new ConcurrentDictionary>();
+
+ ArgumentNullException.ThrowIfNull(loggerFactory);
+ _logger = loggerFactory.CreateLogger();
+ }
+
+ public HttpMessageInvoker Get(DownstreamRoute downstreamRoute)
+ {
+ // Since the comparison is based on the downstream route object reference,
+ // and the QoS Options properties can't be changed after the route is created,
+ // we don't need to use the timeout value as part of the cache key.
+ return _handlersPool.GetOrAdd(
+ new MessageInvokerCacheKey(downstreamRoute),
+ cacheKey => new Lazy(() => CreateMessageInvoker(cacheKey.DownstreamRoute))
+ ).Value;
+ }
+
+ public void Clear() => _handlersPool.Clear();
+
+ private HttpMessageInvoker CreateMessageInvoker(DownstreamRoute downstreamRoute)
+ {
+ var baseHandler = CreateHandler(downstreamRoute);
+ var handlers = _handlerFactory.Get(downstreamRoute).Data;
+ handlers.Reverse();
+
+ foreach (var delegatingHandler in handlers.Select(handler => handler()))
+ {
+ delegatingHandler.InnerHandler = baseHandler;
+ baseHandler = delegatingHandler;
+ }
+
+ // Adding timeout handler to the top of the chain.
+ // It's standard behavior to throw TimeoutException after the defined timeout (90 seconds by default)
+ var timeoutHandler = new TimeoutDelegatingHandler(downstreamRoute.QosOptions.TimeoutValue == 0
+ ? TimeSpan.FromSeconds(DefaultRequestTimeoutSeconds)
+ : TimeSpan.FromMilliseconds(downstreamRoute.QosOptions.TimeoutValue))
+ {
+ InnerHandler = baseHandler,
+ };
+
+ return new HttpMessageInvoker(timeoutHandler, true);
+ }
+
+ private HttpMessageHandler CreateHandler(DownstreamRoute downstreamRoute)
+ {
+ var handler = new SocketsHttpHandler
+ {
+ AllowAutoRedirect = downstreamRoute.HttpHandlerOptions.AllowAutoRedirect,
+ UseCookies = downstreamRoute.HttpHandlerOptions.UseCookieContainer,
+ UseProxy = downstreamRoute.HttpHandlerOptions.UseProxy,
+ MaxConnectionsPerServer = downstreamRoute.HttpHandlerOptions.MaxConnectionsPerServer,
+ PooledConnectionLifetime = downstreamRoute.HttpHandlerOptions.PooledConnectionLifeTime,
+ };
+
+ if (downstreamRoute.HttpHandlerOptions.UseCookieContainer)
+ {
+ handler.CookieContainer = new CookieContainer();
+ }
+
+ if (!downstreamRoute.DangerousAcceptAnyServerCertificateValidator)
+ {
+ return handler;
+ }
+
+ handler.SslOptions = new SslClientAuthenticationOptions
+ {
+ RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true,
+ };
+
+ _logger.LogWarning(() =>
+ $"You have ignored all SSL warnings by using DangerousAcceptAnyServerCertificateValidator for this DownstreamRoute, UpstreamPathTemplate: {downstreamRoute.UpstreamPathTemplate}, DownstreamPathTemplate: {downstreamRoute.DownstreamPathTemplate}");
+
+ return handler;
+ }
+
+ private readonly struct MessageInvokerCacheKey(DownstreamRoute downstreamRoute) : IEquatable
+ {
+ public DownstreamRoute DownstreamRoute { get; } = downstreamRoute;
+
+ public override bool Equals(object obj) => obj is MessageInvokerCacheKey key && Equals(key);
+
+ public bool Equals(MessageInvokerCacheKey other) =>
+ EqualityComparer.Default.Equals(DownstreamRoute, other.DownstreamRoute);
+
+ public override int GetHashCode() => DownstreamRoute.GetHashCode();
+
+ public static bool operator ==(MessageInvokerCacheKey left, MessageInvokerCacheKey right) => left.Equals(right);
+ public static bool operator !=(MessageInvokerCacheKey left, MessageInvokerCacheKey right) => !(left == right);
+ }
+}
diff --git a/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs b/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs
index d996d2983..e26d6a5b3 100644
--- a/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs
+++ b/src/Ocelot/Requester/Middleware/HttpRequesterMiddleware.cs
@@ -21,10 +21,7 @@ public HttpRequesterMiddleware(RequestDelegate next,
public async Task Invoke(HttpContext httpContext)
{
- var downstreamRoute = httpContext.Items.DownstreamRoute();
-
var response = await _requester.GetResponse(httpContext);
-
CreateLogBasedOnResponse(response);
if (response.IsError)
@@ -36,9 +33,7 @@ public async Task Invoke(HttpContext httpContext)
}
Logger.LogDebug("setting http response message");
-
httpContext.Items.UpsertDownstreamResponse(new DownstreamResponse(response.Data));
-
await _next.Invoke(httpContext);
}
diff --git a/src/Ocelot/Requester/ServiceCollectionExtensions.cs b/src/Ocelot/Requester/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..24d72b972
--- /dev/null
+++ b/src/Ocelot/Requester/ServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Ocelot.Requester;
+
+public static class ServiceCollectionExtensions
+{
+ public static void AddOcelotMessageInvokerPool(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+}
diff --git a/src/Ocelot/Requester/TimeoutDelegatingHandler.cs b/src/Ocelot/Requester/TimeoutDelegatingHandler.cs
new file mode 100644
index 000000000..8f4effdfb
--- /dev/null
+++ b/src/Ocelot/Requester/TimeoutDelegatingHandler.cs
@@ -0,0 +1,31 @@
+namespace Ocelot.Requester;
+
+public class TimeoutDelegatingHandler : DelegatingHandler
+{
+ private readonly TimeSpan _timeout;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The time span after which the request is cancelled.
+ public TimeoutDelegatingHandler(TimeSpan timeout)
+ {
+ _timeout = timeout;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(_timeout);
+
+ try
+ {
+ return await base.SendAsync(request, cts.Token);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException();
+ }
+ }
+}
diff --git a/src/Ocelot/Responder/HttpContextResponder.cs b/src/Ocelot/Responder/HttpContextResponder.cs
index 2c21e0c9e..326c4d95a 100644
--- a/src/Ocelot/Responder/HttpContextResponder.cs
+++ b/src/Ocelot/Responder/HttpContextResponder.cs
@@ -4,97 +4,93 @@
using Ocelot.Headers;
using Ocelot.Middleware;
-namespace Ocelot.Responder
+namespace Ocelot.Responder;
+
+///
+/// Cannot unit test things in this class due to methods not being implemented on .NET concretes used for testing.
+///
+public class HttpContextResponder : IHttpResponder
{
- ///
- /// Cannot unit test things in this class due to methods not being implemented
- /// on .net concretes used for testing.
- ///
- public class HttpContextResponder : IHttpResponder
+ private readonly IRemoveOutputHeaders _removeOutputHeaders;
+
+ public HttpContextResponder(IRemoveOutputHeaders removeOutputHeaders)
{
- private readonly IRemoveOutputHeaders _removeOutputHeaders;
+ _removeOutputHeaders = removeOutputHeaders;
+ }
- public HttpContextResponder(IRemoveOutputHeaders removeOutputHeaders)
- {
- _removeOutputHeaders = removeOutputHeaders;
- }
+ public async Task SetResponseOnHttpContext(HttpContext context, DownstreamResponse response)
+ {
+ _removeOutputHeaders.Remove(response.Headers);
- public async Task SetResponseOnHttpContext(HttpContext context, DownstreamResponse response)
+ foreach (var httpResponseHeader in response.Headers)
{
- _removeOutputHeaders.Remove(response.Headers);
-
- foreach (var httpResponseHeader in response.Headers)
- {
- AddHeaderIfDoesntExist(context, httpResponseHeader);
- }
-
- SetStatusCode(context, (int)response.StatusCode);
+ AddHeaderIfDoesntExist(context, httpResponseHeader);
+ }
- context.Response.HttpContext.Features.Get().ReasonPhrase = response.ReasonPhrase;
+ SetStatusCode(context, (int)response.StatusCode);
- if (response.Content is null)
- {
- return;
- }
+ context.Response.HttpContext.Features.Get().ReasonPhrase = response.ReasonPhrase;
- foreach (var httpResponseHeader in response.Content.Headers)
- {
- AddHeaderIfDoesntExist(context, new Header(httpResponseHeader.Key, httpResponseHeader.Value));
- }
+ // As of 5.0 HttpResponse.Content never returns null.
+ // https://github.com/dotnet/runtime/blame/8fc68f626a11d646109a758cb0fc70a0aa7826f1/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs#L46
+ // TODO: Check if it applies to ocelot custom implementation
+ if (response.Content is null)
+ {
+ return;
+ }
- var content = await response.Content.ReadAsStreamAsync();
+ foreach (var httpResponseHeader in response.Content.Headers)
+ {
+ AddHeaderIfDoesntExist(context, new Header(httpResponseHeader.Key, httpResponseHeader.Value));
+ }
- if (response.Content.Headers.ContentLength != null)
- {
- AddHeaderIfDoesntExist(context, new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() }));
- }
+ if (response.Content.Headers.ContentLength != null)
+ {
+ AddHeaderIfDoesntExist(context,
+ new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() }));
+ }
- await using (content)
- {
- if (response.StatusCode != HttpStatusCode.NotModified && context.Response.ContentLength != 0)
- {
- await content.CopyToAsync(context.Response.Body);
- }
- }
+ if (response.StatusCode != HttpStatusCode.NotModified && context.Response.ContentLength != 0)
+ {
+ await using var content = await response.Content.ReadAsStreamAsync();
+ await content.CopyToAsync(context.Response.Body, context.RequestAborted);
}
+ }
+
+ public void SetErrorResponseOnContext(HttpContext context, int statusCode)
+ {
+ SetStatusCode(context, statusCode);
+ }
- public void SetErrorResponseOnContext(HttpContext context, int statusCode)
+ public async Task SetErrorResponseOnContext(HttpContext context, DownstreamResponse response)
+ {
+ if (response.Content.Headers.ContentLength != null)
{
- SetStatusCode(context, statusCode);
+ AddHeaderIfDoesntExist(context,
+ new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() }));
}
- public async Task SetErrorResponseOnContext(HttpContext context, DownstreamResponse response)
+ if (context.Response.ContentLength != 0)
{
- var content = await response.Content.ReadAsStreamAsync();
-
- if (response.Content.Headers.ContentLength != null)
- {
- AddHeaderIfDoesntExist(context, new Header("Content-Length", new[] { response.Content.Headers.ContentLength.ToString() }));
- }
-
- await using (content)
- {
- if (context.Response.ContentLength != 0)
- {
- await content.CopyToAsync(context.Response.Body);
- }
- }
+ await using var content = await response.Content.ReadAsStreamAsync();
+ await content.CopyToAsync(context.Response.Body, context.RequestAborted);
}
+ }
- private static void SetStatusCode(HttpContext context, int statusCode)
+ private static void SetStatusCode(HttpContext context, int statusCode)
+ {
+ if (!context.Response.HasStarted)
{
- if (!context.Response.HasStarted)
- {
- context.Response.StatusCode = statusCode;
- }
+ context.Response.StatusCode = statusCode;
}
+ }
- private static void AddHeaderIfDoesntExist(HttpContext context, Header httpResponseHeader)
+ private static void AddHeaderIfDoesntExist(HttpContext context, Header httpResponseHeader)
+ {
+ if (!context.Response.Headers.ContainsKey(httpResponseHeader.Key))
{
- if (!context.Response.Headers.ContainsKey(httpResponseHeader.Key))
- {
- context.Response.Headers.Append(httpResponseHeader.Key, new StringValues(httpResponseHeader.Values.ToArray()));
- }
+ context.Response.Headers.Append(httpResponseHeader.Key,
+ new StringValues(httpResponseHeader.Values.ToArray()));
}
}
}
diff --git a/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs b/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs
index 76db720a1..a79a22c18 100644
--- a/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs
+++ b/src/Ocelot/Responder/Middleware/ResponderMiddleware.cs
@@ -18,8 +18,7 @@ public class ResponderMiddleware : OcelotMiddleware
public ResponderMiddleware(RequestDelegate next,
IHttpResponder responder,
IOcelotLoggerFactory loggerFactory,
- IErrorsToHttpStatusCodeMapper codeMapper
- )
+ IErrorsToHttpStatusCodeMapper codeMapper)
: base(loggerFactory.CreateLogger())
{
_next = next;
@@ -32,38 +31,43 @@ public async Task Invoke(HttpContext httpContext)
await _next.Invoke(httpContext);
var errors = httpContext.Items.Errors();
- var downstreamResponse = httpContext.Items.DownstreamResponse();
- // todo check errors is ok
+ // We are going to dispose the http request message and content in
+ // this middleware (no further use). That's why we are using the 'using' statement.
+ using var downstreamResponse = httpContext.Items.DownstreamResponse();
+
if (errors.Count > 0)
{
- Logger.LogWarning(() => $"{errors.ToErrorString()} errors found in {MiddlewareName}. Setting error response for request path:{httpContext.Request.Path}, request method: {httpContext.Request.Method}");
+ Logger.LogWarning(() =>
+ $"{errors.ToErrorString()} errors found in {MiddlewareName}. Setting error response for request path:{httpContext.Request.Path}, request method: {httpContext.Request.Method}");
+ await SetErrorResponse(httpContext, errors);
- SetErrorResponse(httpContext, errors);
+ return;
}
- else if (downstreamResponse == null)
+
+ if (downstreamResponse == null)
{
Logger.LogDebug(() => $"Pipeline was terminated early in {MiddlewareName}");
+ return;
}
- else
- {
- Logger.LogDebug("no pipeline errors, setting and returning completed response");
- await _responder.SetResponseOnHttpContext(httpContext, downstreamResponse);
- }
+ Logger.LogDebug("no pipeline errors, setting and returning completed response");
+ await _responder.SetResponseOnHttpContext(httpContext, downstreamResponse);
}
- private void SetErrorResponse(HttpContext context, List errors)
+ private async Task SetErrorResponse(HttpContext context, List errors)
{
- //todo - refactor this all teh way down because its shit
+ // TODO The exception/error handling should be reviewed and refactored.
var statusCode = _codeMapper.Map(errors);
_responder.SetErrorResponseOnContext(context, statusCode);
- if (errors.Any(e => e.Code == OcelotErrorCode.QuotaExceededError))
+ if (errors.All(e => e.Code != OcelotErrorCode.QuotaExceededError))
{
- var downstreamResponse = context.Items.DownstreamResponse();
- _responder.SetErrorResponseOnContext(context, downstreamResponse);
+ return;
}
+
+ var downstreamResponse = context.Items.DownstreamResponse();
+ await _responder.SetErrorResponseOnContext(context, downstreamResponse);
}
}
}
diff --git a/src/Ocelot/Values/DownstreamPathTemplate.cs b/src/Ocelot/Values/DownstreamPathTemplate.cs
index de2a30b8c..028401331 100644
--- a/src/Ocelot/Values/DownstreamPathTemplate.cs
+++ b/src/Ocelot/Values/DownstreamPathTemplate.cs
@@ -8,5 +8,7 @@ public DownstreamPathTemplate(string value)
}
public string Value { get; }
+
+ public override string ToString() => Value ?? string.Empty;
}
}
diff --git a/test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs
new file mode 100644
index 000000000..29e1cb286
--- /dev/null
+++ b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationSteps.cs
@@ -0,0 +1,180 @@
+using IdentityServer4.Models;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Ocelot.Configuration.File;
+using System.Security.Claims;
+
+namespace Ocelot.AcceptanceTests.Authentication;
+
+public class AuthenticationSteps : Steps, IDisposable
+{
+ private readonly ServiceHandler _serviceHandler;
+
+ public AuthenticationSteps() : base()
+ {
+ _serviceHandler = new ServiceHandler();
+ }
+
+ public override void Dispose()
+ {
+ _serviceHandler.Dispose();
+ base.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ public static ApiResource CreateApiResource(
+ string apiName,
+ IEnumerable extraScopes = null) => new()
+ {
+ Name = apiName,
+ Description = $"My {apiName} API",
+ Enabled = true,
+ DisplayName = "test",
+ Scopes = new List(extraScopes ?? Enumerable.Empty())
+ {
+ apiName,
+ $"{apiName}.readOnly",
+ },
+ ApiSecrets = new List
+ {
+ new ("secret".Sha256()),
+ },
+ UserClaims = new List
+ {
+ "CustomerId", "LocationId",
+ },
+ };
+
+ protected static Client CreateClientWithSecret(string clientId, Secret secret, AccessTokenType tokenType = AccessTokenType.Jwt, string[] apiScopes = null)
+ {
+ var client = DefaultClient(tokenType, apiScopes);
+ client.ClientId = clientId ?? "client";
+ client.ClientSecrets = new Secret[] { secret };
+ return client;
+ }
+
+ protected static Client DefaultClient(AccessTokenType tokenType = AccessTokenType.Jwt, string[] apiScopes = null)
+ {
+ apiScopes ??= ["api"];
+ return new()
+ {
+ ClientId = "client",
+ AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
+ ClientSecrets = new List { new("secret".Sha256()) },
+ AllowedScopes = apiScopes
+ .Union(apiScopes.Select(x => $"{x}.readOnly"))
+ .Union(["openid", "offline_access"])
+ .ToList(),
+ AccessTokenType = tokenType,
+ Enabled = true,
+ RequireClientSecret = false,
+ RefreshTokenExpiration = TokenExpiration.Absolute,
+ };
+ }
+
+ public static IWebHostBuilder CreateIdentityServer(string url, AccessTokenType tokenType, string[] apiScopes, Client[] clients)
+ {
+ apiScopes ??= ["api"];
+ clients ??= [DefaultClient(tokenType, apiScopes)];
+ var builder = new WebHostBuilder()
+ .UseUrls(url)
+ .UseKestrel()
+ .UseContentRoot(Directory.GetCurrentDirectory())
+ .UseIISIntegration()
+ .UseUrls(url)
+ .ConfigureServices(services =>
+ {
+ services.AddLogging();
+ services.AddIdentityServer()
+ .AddDeveloperSigningCredential()
+ .AddInMemoryApiScopes(apiScopes
+ .Select(apiname => new ApiScope(apiname, apiname.ToUpper())))
+ .AddInMemoryApiResources(apiScopes
+ .Select(x => new { i = Array.IndexOf(apiScopes, x), scope = x })
+ .Select(x => CreateApiResource(x.scope, ["openid", "offline_access"])))
+ .AddInMemoryClients(clients)
+ .AddTestUsers(
+ [
+ new()
+ {
+ Username = "test",
+ Password = "test",
+ SubjectId = "registered|1231231",
+ Claims = new List
+ {
+ new("CustomerId", "123"),
+ new("LocationId", "321"),
+ },
+ },
+ ]);
+ })
+ .Configure(app =>
+ {
+ app.UseIdentityServer();
+ });
+ return builder;
+ }
+
+ internal Task GivenAuthToken(string url, string apiScope)
+ {
+ var form = GivenDefaultAuthTokenForm();
+ form.RemoveAll(x => x.Key == "scope");
+ form.Add(new("scope", apiScope));
+ return GivenIHaveATokenWithForm(url, form);
+ }
+
+ internal Task GivenAuthToken(string url, string apiScope, string client)
+ {
+ var form = GivenDefaultAuthTokenForm();
+
+ form.RemoveAll(x => x.Key == "scope");
+ form.Add(new("scope", apiScope));
+
+ form.RemoveAll(x => x.Key == "client_id");
+ form.Add(new("client_id", client));
+
+ return GivenIHaveATokenWithForm(url, form);
+ }
+
+ public static FileRoute GivenDefaultAuthRoute(int port, string upstreamHttpMethod = null, string authProviderKey = null) => new()
+ {
+ DownstreamPathTemplate = "/",
+ DownstreamHostAndPorts =
+ [
+ new("localhost", port),
+ ],
+ DownstreamScheme = Uri.UriSchemeHttp,
+ UpstreamPathTemplate = "/",
+ UpstreamHttpMethod = [upstreamHttpMethod ?? HttpMethods.Get],
+ AuthenticationOptions = new FileAuthenticationOptions
+ {
+ AuthenticationProviderKey = authProviderKey ?? "Test",
+ },
+ };
+
+ public static FileConfiguration GivenConfiguration(params FileRoute[] routes)
+ {
+ var configuration = new FileConfiguration();
+ configuration.Routes.AddRange(routes);
+ return configuration;
+ }
+
+ protected void GivenThereIsAServiceRunningOn(int port, HttpStatusCode statusCode, string responseBody)
+ {
+ var url = DownstreamServiceUrl(port);
+ GivenThereIsAServiceRunningOn(url, statusCode, responseBody);
+ }
+
+ protected void GivenThereIsAServiceRunningOn(string url, HttpStatusCode statusCode, string responseBody)
+ {
+ _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
+ {
+ context.Response.StatusCode = (int)statusCode;
+ await context.Response.WriteAsync(responseBody);
+ });
+ }
+
+ protected static string DownstreamServiceUrl(int port) => string.Concat("http://localhost:", port);
+}
diff --git a/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs
new file mode 100644
index 000000000..111cd3afe
--- /dev/null
+++ b/test/Ocelot.AcceptanceTests/Authentication/AuthenticationTests.cs
@@ -0,0 +1,130 @@
+using IdentityServer4.AccessTokenValidation;
+using IdentityServer4.Models;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+
+namespace Ocelot.AcceptanceTests.Authentication
+{
+ public sealed class AuthenticationTests : AuthenticationSteps, IDisposable
+ {
+ private IWebHost _identityServerBuilder;
+ private readonly string _identityServerRootUrl;
+ private readonly Action _options;
+
+ public AuthenticationTests()
+ {
+ var identityServerPort = PortFinder.GetRandomPort();
+ _identityServerRootUrl = $"http://localhost:{identityServerPort}";
+ _options = o =>
+ {
+ o.Authority = _identityServerRootUrl;
+ o.ApiName = "api";
+ o.RequireHttpsMetadata = false;
+ o.SupportedTokens = SupportedTokens.Both;
+ o.ApiSecret = "secret";
+ };
+ }
+
+ [Fact]
+ public void Should_return_401_using_identity_server_access_token()
+ {
+ var port = PortFinder.GetRandomPort();
+ var route = GivenDefaultAuthRoute(port, HttpMethods.Post);
+ var configuration = GivenConfiguration(route);
+ this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt))
+ .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.Created, string.Empty))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenThePostHasContent("postContent"))
+ .When(x => WhenIPostUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_return_response_200_using_identity_server()
+ {
+ var port = PortFinder.GetRandomPort();
+ var route = GivenDefaultAuthRoute(port);
+ var configuration = GivenConfiguration(route);
+ this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt))
+ .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.OK, "Hello from Laura"))
+ .And(x => GivenIHaveAToken(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_return_response_401_using_identity_server_with_token_requested_for_other_api()
+ {
+ var port = PortFinder.GetRandomPort();
+ var route = GivenDefaultAuthRoute(port);
+ var configuration = GivenConfiguration(route);
+ this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt))
+ .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.OK, "Hello from Laura"))
+ .And(x => GivenAuthToken(_identityServerRootUrl, "api2"))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_return_201_using_identity_server_access_token()
+ {
+ var port = PortFinder.GetRandomPort();
+ var route = GivenDefaultAuthRoute(port, HttpMethods.Post);
+ var configuration = GivenConfiguration(route);
+ this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Jwt))
+ .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.Created, string.Empty))
+ .And(x => GivenIHaveAToken(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .And(x => GivenThePostHasContent("postContent"))
+ .When(x => WhenIPostUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Created))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_return_201_using_identity_server_reference_token()
+ {
+ var port = PortFinder.GetRandomPort();
+ var route = GivenDefaultAuthRoute(port, HttpMethods.Post);
+ var configuration = GivenConfiguration(route);
+ this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, AccessTokenType.Reference))
+ .And(x => x.GivenThereIsAServiceRunningOn(DownstreamServiceUrl(port), HttpStatusCode.Created, string.Empty))
+ .And(x => GivenIHaveAToken(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .And(x => GivenThePostHasContent("postContent"))
+ .When(x => WhenIPostUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Created))
+ .BDDfy();
+ }
+
+ private void GivenThereIsAnIdentityServerOn(string url, AccessTokenType tokenType)
+ {
+ var scopes = new string[] { "api", "api2" };
+ _identityServerBuilder = CreateIdentityServer(url, tokenType, scopes, null)
+ .Build();
+ _identityServerBuilder.Start();
+ VerifyIdentityServerStarted(url);
+ }
+
+ public override void Dispose()
+ {
+ _identityServerBuilder?.Dispose();
+ base.Dispose();
+ }
+ }
+}
diff --git a/test/Ocelot.AcceptanceTests/Authentication/MultipleAuthSchemesFeatureTests.cs b/test/Ocelot.AcceptanceTests/Authentication/MultipleAuthSchemesFeatureTests.cs
new file mode 100644
index 000000000..204441497
--- /dev/null
+++ b/test/Ocelot.AcceptanceTests/Authentication/MultipleAuthSchemesFeatureTests.cs
@@ -0,0 +1,159 @@
+using IdentityServer4.AccessTokenValidation;
+using IdentityServer4.Models;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Net.Http.Headers;
+using Ocelot.DependencyInjection;
+using System.Net.Http.Headers;
+
+namespace Ocelot.AcceptanceTests.Authentication;
+
+[Trait("PR", "1870")]
+[Trait("Issues", "740 1580")]
+public sealed class MultipleAuthSchemesFeatureTests : AuthenticationSteps, IDisposable
+{
+ private IWebHost[] _identityServers;
+ private string[] _identityServerUrls;
+ private BearerToken[] _tokens;
+
+ public MultipleAuthSchemesFeatureTests() : base()
+ {
+ _identityServers = [];
+ _identityServerUrls = [];
+ _tokens = [];
+ }
+
+ public override void Dispose()
+ {
+ foreach (var server in _identityServers)
+ {
+ server.Dispose();
+ }
+
+ base.Dispose();
+ }
+
+ private MultipleAuthSchemesFeatureTests Setup(int totalSchemes)
+ {
+ _identityServers = new IWebHost[totalSchemes];
+ _identityServerUrls = new string[totalSchemes];
+ _tokens = new BearerToken[totalSchemes];
+ return this;
+ }
+
+ [Theory]
+ [InlineData("Test1", "Test2")] // with multiple schemes
+ [InlineData(IdentityServerAuthenticationDefaults.AuthenticationScheme, "Test")] // with default scheme
+ [InlineData("Test", IdentityServerAuthenticationDefaults.AuthenticationScheme)] // with default scheme
+ public void Should_authenticate_using_identity_server_with_multiple_schemes(string scheme1, string scheme2)
+ {
+ var port = PortFinder.GetRandomPort();
+ var route = GivenDefaultAuthRoute(port, authProviderKey: string.Empty);
+ var authSchemes = new string[] { scheme1, scheme2 };
+ route.AuthenticationOptions.AuthenticationProviderKeys = authSchemes;
+ var configuration = GivenConfiguration(route);
+ var responseBody = nameof(Should_authenticate_using_identity_server_with_multiple_schemes);
+
+ this.Given(x => GivenThereIsAServiceRunningOn(port, HttpStatusCode.OK, responseBody))
+ .And(x => Setup(authSchemes.Length)
+ .GivenIdentityServerWithScopes(0, "invalid", "unknown")
+ .GivenIdentityServerWithScopes(1, "api1", "api2"))
+ .And(x => GivenIHaveTokenWithScope(0, "invalid")) // authentication should fail because of invalid scope
+ .And(x => GivenIHaveTokenWithScope(1, "api2")) // authentication should succeed
+
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunningWithIdentityServerAuthSchemes("api2", authSchemes))
+ .And(x => GivenIHaveAddedAllAuthHeaders(authSchemes))
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => ThenTheResponseBodyShouldBe(responseBody))
+ .BDDfy();
+ }
+
+ private MultipleAuthSchemesFeatureTests GivenIdentityServerWithScopes(int index, params string[] scopes)
+ {
+ var tokenType = AccessTokenType.Jwt;
+ string url = _identityServerUrls[index] = $"http://localhost:{PortFinder.GetRandomPort()}";
+ var clients = new Client[] { DefaultClient(tokenType, scopes) };
+ var builder = CreateIdentityServer(url, tokenType, scopes, clients);
+
+ var server = _identityServers[index] = builder.Build();
+ server.Start();
+ VerifyIdentityServerStarted(url);
+ return this;
+ }
+
+ private async Task GivenIHaveTokenWithScope(int index, string scope)
+ {
+ string url = _identityServerUrls[index];
+ _tokens[index] = await GivenAuthToken(url, scope);
+ }
+
+ private async Task GivenIHaveExpiredTokenWithScope(string url, string scope, int index)
+ {
+ _tokens[index] = await GivenAuthToken(url, scope, "expired");
+ }
+
+ private void GivenIHaveAddedAllAuthHeaders(string[] schemes)
+ {
+ // Assume default scheme token is attached as "Authorization" header, for example "Bearer"
+ // But default authentication setup should be ignored in multiple schemes scenario
+ _ocelotClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "failed");
+
+ for (int i = 0; i < schemes.Length && i < _tokens.Length; i++)
+ {
+ var token = _tokens[i];
+ var header = AuthHeaderName(schemes[i]);
+ var hvalue = new AuthenticationHeaderValue(token.TokenType, token.AccessToken);
+ GivenIAddAHeader(header, hvalue.ToString());
+ }
+ }
+
+ private static string AuthHeaderName(string scheme) => $"Oc-{HeaderNames.Authorization}-{scheme}";
+
+ private void GivenOcelotIsRunningWithIdentityServerAuthSchemes(string validScope, params string[] schemes)
+ {
+ const string DefaultScheme = IdentityServerAuthenticationDefaults.AuthenticationScheme;
+ GivenOcelotIsRunningWithServices(services =>
+ {
+ services.AddOcelot();
+ var auth = services
+ .AddAuthentication(options =>
+ {
+ options.DefaultScheme = "MultipleSchemes";
+ options.DefaultChallengeScheme = "MultipleSchemes";
+ });
+ for (int i = 0; i < schemes.Length; i++)
+ {
+ var scheme = schemes[i];
+ var identityServerUrl = _identityServerUrls[i];
+ auth.AddIdentityServerAuthentication(scheme, o =>
+ {
+ o.Authority = identityServerUrl;
+ o.ApiName = validScope;
+ o.ApiSecret = "secret";
+ o.RequireHttpsMetadata = false;
+ o.SupportedTokens = SupportedTokens.Both;
+
+ // TODO TokenRetriever ?
+ o.ForwardDefaultSelector = (context) =>
+ {
+ var headers = context.Request.Headers;
+ var name = AuthHeaderName(scheme);
+ if (headers.ContainsKey(name))
+ {
+ // Redirect to default authentication handler which is (JwtAuthHandler) aka (Bearer)
+ headers[HeaderNames.Authorization] = headers[name];
+ return scheme;
+ }
+
+ // Something wrong with the setup: no headers, no tokens.
+ // Redirect to default scheme to read token from default header
+ return DefaultScheme;
+ };
+ });
+ }
+ });
+ }
+}
diff --git a/test/Ocelot.AcceptanceTests/AuthenticationTests.cs b/test/Ocelot.AcceptanceTests/AuthenticationTests.cs
deleted file mode 100644
index 35329847f..000000000
--- a/test/Ocelot.AcceptanceTests/AuthenticationTests.cs
+++ /dev/null
@@ -1,377 +0,0 @@
-using IdentityServer4.AccessTokenValidation;
-using IdentityServer4.Models;
-using IdentityServer4.Test;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
-using Ocelot.Configuration.File;
-using System.Security.Claims;
-
-namespace Ocelot.AcceptanceTests
-{
- public class AuthenticationTests : IDisposable
- {
- private readonly Steps _steps;
- private IWebHost _identityServerBuilder;
- private readonly string _identityServerRootUrl;
- private readonly string _downstreamServicePath = "/";
- private readonly string _downstreamServiceHost = "localhost";
- private readonly string _downstreamServiceScheme = "http";
- private readonly string _downstreamServiceUrl = "http://localhost:";
- private readonly Action _options;
- private readonly ServiceHandler _serviceHandler;
-
- public AuthenticationTests()
- {
- _serviceHandler = new ServiceHandler();
- _steps = new Steps();
- var identityServerPort = PortFinder.GetRandomPort();
- _identityServerRootUrl = $"http://localhost:{identityServerPort}";
- _options = o =>
- {
- o.Authority = _identityServerRootUrl;
- o.ApiName = "api";
- o.RequireHttpsMetadata = false;
- o.SupportedTokens = SupportedTokens.Both;
- o.ApiSecret = "secret";
- };
- }
-
- [Fact]
- public void should_return_401_using_identity_server_access_token()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = _downstreamServicePath,
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host =_downstreamServiceHost,
- Port = port,
- },
- },
- DownstreamScheme = _downstreamServiceScheme,
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Post" },
- AuthenticationOptions = new FileAuthenticationOptions
- {
- AuthenticationProviderKey = "Test",
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt))
- .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 201, string.Empty))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenThePostHasContent("postContent"))
- .When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized))
- .BDDfy();
- }
-
- [Fact]
- public void should_return_response_200_using_identity_server()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = _downstreamServicePath,
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host =_downstreamServiceHost,
- Port = port,
- },
- },
- DownstreamScheme = _downstreamServiceScheme,
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- AuthenticationOptions = new FileAuthenticationOptions
- {
- AuthenticationProviderKey = "Test",
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt))
- .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .BDDfy();
- }
-
- [Fact]
- public void should_return_response_401_using_identity_server_with_token_requested_for_other_api()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = _downstreamServicePath,
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host =_downstreamServiceHost,
- Port = port,
- },
- },
- DownstreamScheme = _downstreamServiceScheme,
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- AuthenticationOptions = new FileAuthenticationOptions
- {
- AuthenticationProviderKey = "Test",
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt))
- .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenIHaveATokenForApi2(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Unauthorized))
- .BDDfy();
- }
-
- [Fact]
- public void should_return_201_using_identity_server_access_token()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = _downstreamServicePath,
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host =_downstreamServiceHost,
- Port = port,
- },
- },
- DownstreamScheme = _downstreamServiceScheme,
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Post" },
- AuthenticationOptions = new FileAuthenticationOptions
- {
- AuthenticationProviderKey = "Test",
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Jwt))
- .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 201, string.Empty))
- .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .And(x => _steps.GivenThePostHasContent("postContent"))
- .When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created))
- .BDDfy();
- }
-
- [Fact]
- public void should_return_201_using_identity_server_reference_token()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = _downstreamServicePath,
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host =_downstreamServiceHost,
- Port = port,
- },
- },
- DownstreamScheme = _downstreamServiceScheme,
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Post" },
- AuthenticationOptions = new FileAuthenticationOptions
- {
- AuthenticationProviderKey = "Test",
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", "api2", AccessTokenType.Reference))
- .And(x => x.GivenThereIsAServiceRunningOn($"{_downstreamServiceUrl}{port}", 201, string.Empty))
- .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .And(x => _steps.GivenThePostHasContent("postContent"))
- .When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created))
- .BDDfy();
- }
-
- private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody)
- {
- _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
- {
- context.Response.StatusCode = statusCode;
- await context.Response.WriteAsync(responseBody);
- });
- }
-
- private void GivenThereIsAnIdentityServerOn(string url, string apiName, string api2Name, AccessTokenType tokenType)
- {
- _identityServerBuilder = new WebHostBuilder()
- .UseUrls(url)
- .UseKestrel()
- .UseContentRoot(Directory.GetCurrentDirectory())
- .UseIISIntegration()
- .UseUrls(url)
- .ConfigureServices(services =>
- {
- services.AddLogging();
- services.AddIdentityServer()
- .AddDeveloperSigningCredential()
- .AddInMemoryApiScopes(new List
- {
- new(apiName, "test"),
- new(api2Name, "test"),
- })
- .AddInMemoryApiResources(new List
- {
- new()
- {
- Name = apiName,
- Description = "My API",
- Enabled = true,
- DisplayName = "test",
- Scopes = new List
- {
- "api",
- "api.readOnly",
- "openid",
- "offline_access",
- },
- ApiSecrets = new List
- {
- new()
- {
- Value = "secret".Sha256(),
- },
- },
- UserClaims = new List
- {
- "CustomerId", "LocationId",
- },
- },
- new()
- {
- Name = api2Name,
- Description = "My second API",
- Enabled = true,
- DisplayName = "second test",
- Scopes = new List
- {
- "api2",
- "api2.readOnly",
- },
- ApiSecrets = new List
- {
- new()
- {
- Value = "secret".Sha256(),
- },
- },
- UserClaims = new List
- {
- "CustomerId", "LocationId",
- },
- },
- })
- .AddInMemoryClients(new List
- {
- new()
- {
- ClientId = "client",
- AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
- ClientSecrets = new List {new("secret".Sha256())},
- AllowedScopes = new List { apiName, api2Name, "api.readOnly", "openid", "offline_access" },
- AccessTokenType = tokenType,
- Enabled = true,
- RequireClientSecret = false,
- },
- })
- .AddTestUsers(new List
- {
- new()
- {
- Username = "test",
- Password = "test",
- SubjectId = "registered|1231231",
- Claims = new List
- {
- new("CustomerId", "123"),
- new("LocationId", "321"),
- },
- },
- });
- })
- .Configure(app =>
- {
- app.UseIdentityServer();
- })
- .Build();
-
- _identityServerBuilder.Start();
-
- Steps.VerifyIdentityServerStarted(url);
- }
-
- public void Dispose()
- {
- _serviceHandler.Dispose();
- _steps.Dispose();
- _identityServerBuilder?.Dispose();
- }
- }
-}
diff --git a/test/Ocelot.AcceptanceTests/AuthorizationTests.cs b/test/Ocelot.AcceptanceTests/AuthorizationTests.cs
index 26dcfb1a4..58f1852a4 100644
--- a/test/Ocelot.AcceptanceTests/AuthorizationTests.cs
+++ b/test/Ocelot.AcceptanceTests/AuthorizationTests.cs
@@ -5,15 +5,15 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
+using Ocelot.AcceptanceTests.Authentication;
using Ocelot.Configuration.File;
using System.Security.Claims;
namespace Ocelot.AcceptanceTests
{
- public class AuthorizationTests : IDisposable
+ public class AuthorizationTests : AuthenticationSteps, IDisposable
{
private IWebHost _identityServerBuilder;
- private readonly Steps _steps;
private readonly Action _options;
private readonly string _identityServerRootUrl;
private readonly ServiceHandler _serviceHandler;
@@ -21,7 +21,6 @@ public class AuthorizationTests : IDisposable
public AuthorizationTests()
{
_serviceHandler = new ServiceHandler();
- _steps = new Steps();
var identityServerPort = PortFinder.GetRandomPort();
_identityServerRootUrl = $"http://localhost:{identityServerPort}";
_options = o =>
@@ -84,13 +83,13 @@ public void should_return_response_200_authorizing_route()
this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt))
.And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .And(x => GivenIHaveAToken(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => ThenTheResponseBodyShouldBe("Hello from Laura"))
.BDDfy();
}
@@ -143,12 +142,12 @@ public void should_return_response_403_authorizing_route()
this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt))
.And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden))
+ .And(x => GivenIHaveAToken(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden))
.BDDfy();
}
@@ -186,12 +185,12 @@ public void should_return_response_200_using_identity_server_with_allowed_scope(
this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt))
.And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
.BDDfy();
}
@@ -229,12 +228,12 @@ public void should_return_response_403_using_identity_server_with_scope_not_allo
this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt))
.And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden))
+ .And(x => GivenIHaveATokenForApiReadOnlyScope(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.Forbidden))
.BDDfy();
}
@@ -290,13 +289,13 @@ public void should_fix_issue_240()
this.Given(x => x.GivenThereIsAnIdentityServerOn(_identityServerRootUrl, "api", AccessTokenType.Jwt, users))
.And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenIHaveAToken(_identityServerRootUrl))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning(_options, "Test"))
- .And(x => _steps.GivenIHaveAddedATokenToMyRequest())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .And(x => GivenIHaveAToken(_identityServerRootUrl))
+ .And(x => GivenThereIsAConfiguration(configuration))
+ .And(x => GivenOcelotIsRunning(_options, "Test"))
+ .And(x => GivenIHaveAddedATokenToMyRequest())
+ .When(x => WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => ThenTheResponseBodyShouldBe("Hello from Laura"))
.BDDfy();
}
@@ -467,11 +466,14 @@ private void GivenThereIsAnIdentityServerOn(string url, string apiName, AccessTo
Steps.VerifyIdentityServerStarted(url);
}
- public void Dispose()
+ private async Task GivenIHaveATokenForApiReadOnlyScope(string url)
+ => await GivenAuthToken(url, "api.readOnly");
+
+ public override void Dispose()
{
_serviceHandler?.Dispose();
- _steps.Dispose();
_identityServerBuilder?.Dispose();
+ base.Dispose();
}
}
}
diff --git a/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs
new file mode 100644
index 000000000..4eb8a5bf3
--- /dev/null
+++ b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs
@@ -0,0 +1,205 @@
+using Microsoft.AspNetCore.Http;
+using Ocelot.Configuration.File;
+
+namespace Ocelot.AcceptanceTests.Caching
+{
+ public sealed class CachingTests : IDisposable
+ {
+ private readonly Steps _steps;
+ private readonly ServiceHandler _serviceHandler;
+
+ private const string HelloTomContent = "Hello from Tom";
+ private const string HelloLauraContent = "Hello from Laura";
+
+ public CachingTests()
+ {
+ _serviceHandler = new ServiceHandler();
+ _steps = new Steps();
+ }
+
+ [Fact]
+ public void Should_return_cached_response()
+ {
+ var port = PortFinder.GetRandomPort();
+ var options = new FileCacheOptions
+ {
+ TtlSeconds = 100,
+ };
+ var configuration = GivenFileConfiguration(port, options);
+
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, null, null))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .And(x => _steps.ThenTheContentLengthIs(HelloLauraContent.Length))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_return_cached_response_with_expires_header()
+ {
+ var port = PortFinder.GetRandomPort();
+ var options = new FileCacheOptions
+ {
+ TtlSeconds = 100,
+ };
+ var configuration = GivenFileConfiguration(port, options);
+ var headerExpires = "Expires";
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, headerExpires, "-1"))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .And(x => _steps.ThenTheContentLengthIs(HelloLauraContent.Length))
+ .And(x => _steps.ThenTheResponseBodyHeaderIs(headerExpires, "-1"))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_return_cached_response_when_using_jsonserialized_cache()
+ {
+ var port = PortFinder.GetRandomPort();
+ var options = new FileCacheOptions
+ {
+ TtlSeconds = 100,
+ };
+ var configuration = GivenFileConfiguration(port, options);
+
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, null, null))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunningUsingJsonSerializedCache())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_not_return_cached_response_as_ttl_expires()
+ {
+ var port = PortFinder.GetRandomPort();
+ var options = new FileCacheOptions
+ {
+ TtlSeconds = 1,
+ };
+ var configuration = GivenFileConfiguration(port, options);
+
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, null, null))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, null, null))
+ .And(x => GivenTheCacheExpires())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloTomContent))
+ .BDDfy();
+ }
+
+ [Fact]
+ [Trait("Issue", "1172")]
+ public void Should_clean_cached_response_by_cache_header_via_new_caching_key()
+ {
+ var port = PortFinder.GetRandomPort();
+ var options = new FileCacheOptions
+ {
+ TtlSeconds = 100,
+ Region = "europe-central",
+ Header = "Authorization",
+ };
+ var configuration = GivenFileConfiguration(port, options);
+ var headerExpires = "Expires";
+
+ // Add to cache
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", HttpStatusCode.OK, HelloLauraContent, headerExpires, options.TtlSeconds))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+
+ // Read from cache
+ .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, headerExpires, options.TtlSeconds / 2))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloLauraContent))
+ .And(x => _steps.ThenTheContentLengthIs(HelloLauraContent.Length))
+
+ // Clean cache by the header and cache new content
+ .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", HttpStatusCode.OK, HelloTomContent, headerExpires, -1))
+ .And(x => _steps.GivenIAddAHeader(options.Header, "123"))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe(HelloTomContent))
+ .And(x => _steps.ThenTheContentLengthIs(HelloTomContent.Length))
+ .BDDfy();
+ }
+
+ private static FileConfiguration GivenFileConfiguration(int port, FileCacheOptions cacheOptions) => new()
+ {
+ Routes =
+ [
+ new FileRoute()
+ {
+ DownstreamPathTemplate = "/",
+ DownstreamHostAndPorts =
+ [
+ new FileHostAndPort("localhost", port),
+ ],
+ DownstreamScheme = Uri.UriSchemeHttp,
+ UpstreamPathTemplate = "/",
+ UpstreamHttpMethod =["Get"],
+ FileCacheOptions = cacheOptions,
+ },
+ ],
+ };
+
+ private static void GivenTheCacheExpires()
+ {
+ Thread.Sleep(1000);
+ }
+
+ private void GivenTheServiceNowReturns(string url, HttpStatusCode statusCode, string responseBody, string key, object value)
+ {
+ _serviceHandler.Dispose();
+ GivenThereIsAServiceRunningOn(url, statusCode, responseBody, key, value);
+ }
+
+ private void GivenThereIsAServiceRunningOn(string url, HttpStatusCode statusCode, string responseBody, string key, object value)
+ {
+ _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
+ {
+ if (!string.IsNullOrEmpty(key) && value != null)
+ {
+ context.Response.Headers.Append(key, value.ToString());
+ }
+
+ context.Response.StatusCode = (int)statusCode;
+ await context.Response.WriteAsync(responseBody);
+ });
+ }
+
+ public void Dispose()
+ {
+ _serviceHandler?.Dispose();
+ _steps.Dispose();
+ }
+ }
+}
diff --git a/test/Ocelot.AcceptanceTests/CachingTests.cs b/test/Ocelot.AcceptanceTests/CachingTests.cs
deleted file mode 100644
index fc1f91000..000000000
--- a/test/Ocelot.AcceptanceTests/CachingTests.cs
+++ /dev/null
@@ -1,228 +0,0 @@
-using Microsoft.AspNetCore.Http;
-using Ocelot.Configuration.File;
-
-namespace Ocelot.AcceptanceTests
-{
- public class CachingTests : IDisposable
- {
- private readonly Steps _steps;
- private readonly ServiceHandler _serviceHandler;
-
- public CachingTests()
- {
- _serviceHandler = new ServiceHandler();
- _steps = new Steps();
- }
-
- [Fact]
- public void should_return_cached_response()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- DownstreamScheme = "http",
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- FileCacheOptions = new FileCacheOptions
- {
- TtlSeconds = 100,
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", null, null))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .And(x => _steps.ThenTheContentLengthIs(16))
- .BDDfy();
- }
-
- [Fact]
- public void should_return_cached_response_with_expires_header()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- DownstreamScheme = "http",
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- FileCacheOptions = new FileCacheOptions
- {
- TtlSeconds = 100,
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", "Expires", "-1"))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .And(x => _steps.ThenTheContentLengthIs(16))
- .And(x => _steps.ThenTheResponseBodyHeaderIs("Expires", "-1"))
- .BDDfy();
- }
-
- [Fact]
- public void should_return_cached_response_when_using_jsonserialized_cache()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- DownstreamScheme = "http",
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- FileCacheOptions = new FileCacheOptions
- {
- TtlSeconds = 100,
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", null, null))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningUsingJsonSerializedCache())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .BDDfy();
- }
-
- [Fact]
- public void should_not_return_cached_response_as_ttl_expires()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- DownstreamScheme = "http",
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- FileCacheOptions = new FileCacheOptions
- {
- TtlSeconds = 1,
- },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura", null, null))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunning())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .Given(x => x.GivenTheServiceNowReturns($"http://localhost:{port}", 200, "Hello from Tom"))
- .And(x => GivenTheCacheExpires())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Tom"))
- .BDDfy();
- }
-
- private static void GivenTheCacheExpires()
- {
- Thread.Sleep(1000);
- }
-
- private void GivenTheServiceNowReturns(string url, int statusCode, string responseBody)
- {
- _serviceHandler.Dispose();
- GivenThereIsAServiceRunningOn(url, statusCode, responseBody, null, null);
- }
-
- private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody, string key, string value)
- {
- _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
- {
- if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(key))
- {
- context.Response.Headers.Append(key, value);
- }
-
- context.Response.StatusCode = statusCode;
- await context.Response.WriteAsync(responseBody);
- });
- }
-
- public void Dispose()
- {
- _serviceHandler?.Dispose();
- _steps.Dispose();
- }
- }
-}
diff --git a/test/Ocelot.AcceptanceTests/ContentTests.cs b/test/Ocelot.AcceptanceTests/ContentTests.cs
index ba95dfd22..5c64da21f 100644
--- a/test/Ocelot.AcceptanceTests/ContentTests.cs
+++ b/test/Ocelot.AcceptanceTests/ContentTests.cs
@@ -1,50 +1,37 @@
using Microsoft.AspNetCore.Http;
using Ocelot.Configuration.File;
+using System.Diagnostics;
namespace Ocelot.AcceptanceTests
{
- public class ContentTests : IDisposable
+ public sealed class ContentTests : IDisposable
{
- private readonly Steps _steps;
private string _contentType;
private long? _contentLength;
+ private long _memoryUsageAfterCallToService;
private bool _contentTypeHeaderExists;
+
private readonly ServiceHandler _serviceHandler;
+ private readonly Steps _steps;
public ContentTests()
{
_serviceHandler = new ServiceHandler();
_steps = new Steps();
}
+
+ public void Dispose()
+ {
+ _serviceHandler.Dispose();
+ _steps.Dispose();
+ }
[Fact]
- public void should_not_add_content_type_or_content_length_headers()
+ public void Should_Not_add_content_type_or_content_length_headers()
{
var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamScheme = "http",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura"))
+ var configuration = GivenConfiguration(port);
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -56,84 +43,61 @@ public void should_not_add_content_type_or_content_length_headers()
}
[Fact]
- public void should_add_content_type_and_content_length_headers()
+ public void Should_add_content_type_and_content_length_headers()
{
var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- DownstreamScheme = "http",
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Post" },
- },
- },
- };
-
+ var configuration = GivenConfiguration(port, HttpMethods.Post);
var contentType = "application/json";
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 201, string.Empty))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.Created, string.Empty))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.And(x => _steps.GivenThePostHasContent("postContent"))
.And(x => _steps.GivenThePostHasContentType(contentType))
.When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
.Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created))
- .And(x => ThenTheContentLengthIs(11))
.And(x => ThenTheContentTypeIsIs(contentType))
.BDDfy();
}
[Fact]
- public void should_add_default_content_type_header()
+ public void Should_add_default_content_type_header()
{
var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- DownstreamScheme = "http",
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Post" },
- },
- },
- };
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 201, string.Empty))
+ var configuration = GivenConfiguration(port, HttpMethods.Post);
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.Created, string.Empty))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.And(x => _steps.GivenThePostHasContent("postContent"))
.When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
.Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.Created))
- .And(x => ThenTheContentLengthIs(11))
.And(x => ThenTheContentTypeIsIs("text/plain; charset=utf-8"))
.BDDfy();
}
+ [Fact]
+ [Trait("PR", "1824")]
+ [Trait("Issues", "356 695 1924")]
+ public void Should_Not_increase_memory_usage_When_downloading_large_file()
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = GivenConfiguration(port);
+ var dummyDatFilePath = GenerateDummyDatFile(100);
+ this.Given(x => x.GivenThereIsAServiceWithPayloadRunningOn($"http://localhost:{port}", "/", dummyDatFilePath))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .Then(x => x.ThenMemoryUsageShouldNotIncrease())
+ .BDDfy();
+ }
+
+ private void ThenMemoryUsageShouldNotIncrease()
+ {
+ var currentMemoryUsage = Process.GetCurrentProcess().WorkingSet64;
+ var tolerance = currentMemoryUsage - (10 * 1024 * 1024L);
+ Assert.InRange(_memoryUsageAfterCallToService, currentMemoryUsage - tolerance, currentMemoryUsage + tolerance);
+ }
+
private void ThenTheContentTypeIsIs(string expected)
{
_contentType.ShouldBe(expected);
@@ -144,33 +108,82 @@ private void ThenTheContentLengthShouldBeZero()
_contentLength.ShouldBeNull();
}
- private void ThenTheContentLengthIs(int expected)
- {
- _contentLength.ShouldBe(expected);
- }
-
private void ThenTheContentTypeShouldBeEmpty()
{
_contentType.ShouldBeNullOrEmpty();
_contentTypeHeaderExists.ShouldBe(false);
}
- private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody)
+ private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, HttpStatusCode statusCode, string responseBody)
{
_serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context =>
{
_contentType = context.Request.ContentType;
_contentLength = context.Request.ContentLength;
_contentTypeHeaderExists = context.Request.Headers.TryGetValue("Content-Type", out var value);
- context.Response.StatusCode = statusCode;
+ context.Response.StatusCode = (int)statusCode;
await context.Response.WriteAsync(responseBody);
});
}
- public void Dispose()
+ private void GivenThereIsAServiceWithPayloadRunningOn(string baseUrl, string basePath, string dummyDatFilePath)
{
- _serviceHandler?.Dispose();
- _steps.Dispose();
- }
+ _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context =>
+ {
+ context.Response.StatusCode = (int)HttpStatusCode.OK;
+ await using var fileStream = File.OpenRead(dummyDatFilePath);
+ await fileStream.CopyToAsync(context.Response.Body);
+ _memoryUsageAfterCallToService = Process.GetCurrentProcess().WorkingSet64;
+ });
+ }
+
+ ///
+ /// Generates a dummy payload of the given size in MB.
+ /// Avoiding maintaining a large file in the repository.
+ ///
+ /// The file size in MB.
+ /// The payload file path.
+ /// Throwing an exception if the payload path is null.
+ private static string GenerateDummyDatFile(int sizeInMb)
+ {
+ var payloadName = "dummy.dat";
+ var payloadPath = Path.Combine(Directory.GetCurrentDirectory(), payloadName);
+
+ if (File.Exists(payloadPath))
+ {
+ File.Delete(payloadPath);
+ }
+
+ var newFile = new FileStream(payloadPath, FileMode.CreateNew);
+ try
+ {
+ newFile.Seek(sizeInMb * 1024L * 1024, SeekOrigin.Begin);
+ newFile.WriteByte(0);
+ }
+ finally
+ {
+ newFile.Dispose();
+ }
+
+ return payloadPath;
+ }
+
+ private static FileConfiguration GivenConfiguration(int port, string method = null) => new()
+ {
+ Routes =
+ [
+ new FileRoute
+ {
+ DownstreamPathTemplate = "/",
+ DownstreamScheme = Uri.UriSchemeHttp,
+ DownstreamHostAndPorts =
+ [
+ new FileHostAndPort("localhost", port),
+ ],
+ UpstreamPathTemplate = "/",
+ UpstreamHttpMethod = [method ?? HttpMethods.Get],
+ },
+ ],
+ };
}
}
diff --git a/test/Ocelot.AcceptanceTests/HttpClientCachingTests.cs b/test/Ocelot.AcceptanceTests/HttpClientCachingTests.cs
deleted file mode 100644
index eca147ac8..000000000
--- a/test/Ocelot.AcceptanceTests/HttpClientCachingTests.cs
+++ /dev/null
@@ -1,165 +0,0 @@
-using Microsoft.AspNetCore.Http;
-using Ocelot.Configuration;
-using Ocelot.Configuration.File;
-using Ocelot.Requester;
-using System.Collections.Concurrent;
-
-namespace Ocelot.AcceptanceTests
-{
- public class HttpClientCachingTests : IDisposable
- {
- private readonly Steps _steps;
- private readonly ServiceHandler _serviceHandler;
-
- public HttpClientCachingTests()
- {
- _serviceHandler = new ServiceHandler();
- _steps = new Steps();
- }
-
- [Fact]
- public void should_cache_one_http_client_same_re_route()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamScheme = "http",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- },
- },
- };
-
- var cache = new FakeHttpClientCache();
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .And(x => ThenTheCountShouldBe(cache, 1))
- .BDDfy();
- }
-
- [Fact]
- public void should_cache_two_http_client_different_re_route()
- {
- var port = PortFinder.GetRandomPort();
-
- var configuration = new FileConfiguration
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamScheme = "http",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new List { "Get" },
- },
- new()
- {
- DownstreamPathTemplate = "/two",
- DownstreamScheme = "http",
- DownstreamHostAndPorts = new List
- {
- new()
- {
- Host = "localhost",
- Port = port,
- },
- },
- UpstreamPathTemplate = "/two",
- UpstreamHttpMethod = new List { "Get" },
- },
- },
- };
-
- var cache = new FakeHttpClientCache();
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, "Hello from Laura"))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningWithFakeHttpClientCache(cache))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/two"))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .And(x => ThenTheCountShouldBe(cache, 2))
- .BDDfy();
- }
-
- private static void ThenTheCountShouldBe(FakeHttpClientCache cache, int count)
- {
- cache.Count.ShouldBe(count);
- }
-
- private void GivenThereIsAServiceRunningOn(string baseUrl, int statusCode, string responseBody)
- {
- _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, async context =>
- {
- context.Response.StatusCode = statusCode;
- await context.Response.WriteAsync(responseBody);
- });
- }
-
- public void Dispose()
- {
- _serviceHandler.Dispose();
- _steps.Dispose();
- }
-
- public class FakeHttpClientCache : IHttpClientCache
- {
- private readonly ConcurrentDictionary _httpClientsCache;
-
- public FakeHttpClientCache()
- {
- _httpClientsCache = new ConcurrentDictionary();
- }
-
- public void Set(DownstreamRoute key, IHttpClient client, TimeSpan expirationTime)
- {
- _httpClientsCache.AddOrUpdate(key, client, (k, oldValue) => client);
- }
-
- public IHttpClient Get(DownstreamRoute key)
- {
- //todo handle error?
- return _httpClientsCache.TryGetValue(key, out var client) ? client : null;
- }
-
- public int Count => _httpClientsCache.Count;
- }
- }
-}
diff --git a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs
index 558ae7e3a..fda947c81 100644
--- a/test/Ocelot.AcceptanceTests/PollyQoSTests.cs
+++ b/test/Ocelot.AcceptanceTests/PollyQoSTests.cs
@@ -1,6 +1,6 @@
-using Microsoft.AspNetCore.Http;
-using Ocelot.Configuration;
-using Ocelot.Configuration.File;
+using Microsoft.AspNetCore.Http;
+using Ocelot.Configuration;
+using Ocelot.Configuration.File;
namespace Ocelot.AcceptanceTests
{
@@ -9,164 +9,164 @@ public class PollyQoSTests : IDisposable
private readonly Steps _steps;
private readonly ServiceHandler _serviceHandler;
- public PollyQoSTests()
- {
- _serviceHandler = new ServiceHandler();
- _steps = new Steps();
- }
-
- private static FileConfiguration FileConfigurationFactory(int port, QoSOptions options, string httpMethod = nameof(HttpMethods.Get))
- => new()
- {
- Routes = new List
- {
- new()
- {
- DownstreamPathTemplate = "/",
- DownstreamScheme = Uri.UriSchemeHttp,
- DownstreamHostAndPorts = new()
- {
- new("localhost", port),
- },
- UpstreamPathTemplate = "/",
- UpstreamHttpMethod = new() {httpMethod},
- QoSOptions = new FileQoSOptions(options),
- },
- },
- };
+ public PollyQoSTests()
+ {
+ _serviceHandler = new ServiceHandler();
+ _steps = new Steps();
+ }
- [Fact]
- public void Should_not_timeout()
- {
- var port = PortFinder.GetRandomPort();
- var configuration = FileConfigurationFactory(port, new QoSOptions(10, 0, 1000, null), HttpMethods.Post);
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, string.Empty, 10))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningWithPolly())
- .And(x => _steps.GivenThePostHasContent("postContent"))
- .When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .BDDfy();
- }
+ private static FileConfiguration FileConfigurationFactory(int port, QoSOptions options, string httpMethod = nameof(HttpMethods.Get))
+ => new()
+ {
+ Routes = new List
+ {
+ new()
+ {
+ DownstreamPathTemplate = "/",
+ DownstreamScheme = Uri.UriSchemeHttp,
+ DownstreamHostAndPorts = new()
+ {
+ new("localhost", port),
+ },
+ UpstreamPathTemplate = "/",
+ UpstreamHttpMethod = new() {httpMethod},
+ QoSOptions = new FileQoSOptions(options),
+ },
+ },
+ };
+
+ [Fact]
+ public void Should_not_timeout()
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = FileConfigurationFactory(port, new QoSOptions(10, 0, 1000, null), HttpMethods.Post);
+
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 200, string.Empty, 10))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunningWithPolly())
+ .And(x => _steps.GivenThePostHasContent("postContent"))
+ .When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .BDDfy();
+ }
- [Fact]
- public void Should_timeout()
- {
- var port = PortFinder.GetRandomPort();
- var configuration = FileConfigurationFactory(port, new QoSOptions(0, 0, 10, null), HttpMethods.Post);
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 1000))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningWithPolly())
- .And(x => _steps.GivenThePostHasContent("postContent"))
- .When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .BDDfy();
- }
+ [Fact]
+ public void Should_timeout()
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = FileConfigurationFactory(port, new QoSOptions(0, 0, 10, null), HttpMethods.Post);
+
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 1000))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunningWithPolly())
+ .And(x => _steps.GivenThePostHasContent("postContent"))
+ .When(x => _steps.WhenIPostUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .BDDfy();
+ }
- [Fact]
- public void Should_open_circuit_breaker_after_two_exceptions()
- {
- var port = PortFinder.GetRandomPort();
- var configuration = FileConfigurationFactory(port, new QoSOptions(2, 5000, 100000, null));
-
- this.Given(x => x.GivenThereIsABrokenServiceRunningOn($"http://localhost:{port}"))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningWithPolly())
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .BDDfy();
- }
-
- [Fact]
- public void Should_open_circuit_breaker_then_close()
- {
- var port = PortFinder.GetRandomPort();
- var configuration = FileConfigurationFactory(port, new QoSOptions(1, 500, 1000, null));
-
- this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port}", "Hello from Laura"))
- .Given(x => _steps.GivenThereIsAConfiguration(configuration))
- .Given(x => _steps.GivenOcelotIsRunningWithPolly())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .Given(x => GivenIWaitMilliseconds(3000))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .BDDfy();
- }
+ [Fact]
+ public void Should_open_circuit_breaker_after_two_exceptions()
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = FileConfigurationFactory(port, new QoSOptions(2, 5000, 100000, null));
+
+ this.Given(x => x.GivenThereIsABrokenServiceRunningOn($"http://localhost:{port}"))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunningWithPolly())
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Should_open_circuit_breaker_then_close()
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = FileConfigurationFactory(port, new QoSOptions(1, 500, 1000, null));
+
+ this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port}", "Hello from Laura"))
+ .Given(x => _steps.GivenThereIsAConfiguration(configuration))
+ .Given(x => _steps.GivenOcelotIsRunningWithPolly())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .Given(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Given(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .Given(x => GivenIWaitMilliseconds(3000))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .BDDfy();
+ }
+
+ [Fact]
+ public void Open_circuit_should_not_effect_different_route()
+ {
+ var port1 = PortFinder.GetRandomPort();
+ var port2 = PortFinder.GetRandomPort();
+ var qos1 = new QoSOptions(1, 1000, 1000, null);
+
+ var configuration = FileConfigurationFactory(port1, qos1);
+ var route2 = configuration.Routes[0].Clone() as FileRoute;
+ route2.DownstreamHostAndPorts[0].Port = port2;
+ route2.UpstreamPathTemplate = "/working";
+ route2.QoSOptions = new();
+ configuration.Routes.Add(route2);
+
+ this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port1}", "Hello from Laura"))
+ .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port2}", 200, "Hello from Tom", 0))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunningWithPolly())
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/working"))
+ .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Tom"))
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .And(x => GivenIWaitMilliseconds(3000))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .BDDfy();
+ }
[Fact]
- public void Open_circuit_should_not_effect_different_route()
- {
- var port1 = PortFinder.GetRandomPort();
- var port2 = PortFinder.GetRandomPort();
- var qos1 = new QoSOptions(1, 1000, 500, null);
-
- var configuration = FileConfigurationFactory(port1, qos1);
- var route2 = configuration.Routes[0].Clone() as FileRoute;
- route2.DownstreamHostAndPorts[0].Port = port2;
- route2.UpstreamPathTemplate = "/working";
- route2.QoSOptions = new();
- configuration.Routes.Add(route2);
-
- this.Given(x => x.GivenThereIsAPossiblyBrokenServiceRunningOn($"http://localhost:{port1}", "Hello from Laura"))
- .And(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port2}/", 200, "Hello from Tom", 0))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningWithPolly())
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/working"))
- .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Tom"))
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .And(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .And(x => GivenIWaitMilliseconds(3000))
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
- .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
- .BDDfy();
- }
-
- [Fact(DisplayName = "1833: " + nameof(Should_timeout_per_default_after_90_seconds))]
- public void Should_timeout_per_default_after_90_seconds()
- {
- var port = PortFinder.GetRandomPort();
- var configuration = FileConfigurationFactory(port, new QoSOptions(new FileQoSOptions()), HttpMethods.Get);
-
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 95000))
- .And(x => _steps.GivenThereIsAConfiguration(configuration))
- .And(x => _steps.GivenOcelotIsRunningWithPolly())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
- .BDDfy();
+ [Trait("Bug", "1833")]
+ public void Should_timeout_per_default_after_90_seconds()
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = FileConfigurationFactory(port, new QoSOptions(new FileQoSOptions()), HttpMethods.Get);
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", 201, string.Empty, 95000))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunningWithPolly())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.ServiceUnavailable))
+ .BDDfy();
}
- private static void GivenIWaitMilliseconds(int ms) => Thread.Sleep(ms);
-
- private void GivenThereIsABrokenServiceRunningOn(string url)
- {
- _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
- {
- context.Response.StatusCode = 500;
- await context.Response.WriteAsync("this is an exception");
- });
- }
+ private static void GivenIWaitMilliseconds(int ms) => Thread.Sleep(ms);
+
+ private void GivenThereIsABrokenServiceRunningOn(string url)
+ {
+ _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
+ {
+ context.Response.StatusCode = 500;
+ await context.Response.WriteAsync("this is an exception");
+ });
+ }
private void GivenThereIsAPossiblyBrokenServiceRunningOn(string url, string responseBody)
{
@@ -184,21 +184,21 @@ private void GivenThereIsAPossiblyBrokenServiceRunningOn(string url, string resp
});
}
- private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody, int timeout)
- {
- _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
- {
- Thread.Sleep(timeout);
- context.Response.StatusCode = statusCode;
- await context.Response.WriteAsync(responseBody);
- });
- }
-
- public void Dispose()
- {
- _serviceHandler?.Dispose();
- _steps.Dispose();
- GC.SuppressFinalize(this);
- }
- }
+ private void GivenThereIsAServiceRunningOn(string url, int statusCode, string responseBody, int timeout)
+ {
+ _serviceHandler.GivenThereIsAServiceRunningOn(url, async context =>
+ {
+ Thread.Sleep(timeout);
+ context.Response.StatusCode = statusCode;
+ await context.Response.WriteAsync(responseBody);
+ });
+ }
+
+ public void Dispose()
+ {
+ _serviceHandler?.Dispose();
+ _steps.Dispose();
+ GC.SuppressFinalize(this);
+ }
+ }
}
diff --git a/test/Ocelot.AcceptanceTests/RoutingTests.cs b/test/Ocelot.AcceptanceTests/RoutingTests.cs
index 243d973d8..e95d3fab8 100644
--- a/test/Ocelot.AcceptanceTests/RoutingTests.cs
+++ b/test/Ocelot.AcceptanceTests/RoutingTests.cs
@@ -3,16 +3,23 @@
namespace Ocelot.AcceptanceTests
{
- public class RoutingTests : IDisposable
+ public sealed class RoutingTests : IDisposable
{
private readonly Steps _steps;
- private string _downstreamPath;
private readonly ServiceHandler _serviceHandler;
+ private string _downstreamPath;
+ private string _downstreamQuery;
public RoutingTests()
{
_serviceHandler = new ServiceHandler();
_steps = new Steps();
+ }
+
+ public void Dispose()
+ {
+ _serviceHandler.Dispose();
+ _steps.Dispose();
}
[Fact]
@@ -42,7 +49,7 @@ public void should_not_match_forward_slash_in_pattern_before_next_forward_slash(
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/aaaaaaaaa/cards", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/aaaaaaaaa/cards", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/api/v1/aaaaaaaaa/cards"))
@@ -87,7 +94,7 @@ public void should_return_response_200_with_forward_slash_and_placeholder_only()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -138,7 +145,7 @@ public void should_return_response_200_favouring_forward_slash_with_path_route()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/test"))
@@ -188,7 +195,7 @@ public void should_return_response_200_favouring_forward_slash()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -239,7 +246,7 @@ public void should_return_response_200_favouring_forward_slash_route_because_it_
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -275,7 +282,7 @@ public void should_return_response_200_with_nothing_and_placeholder_only()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway(string.Empty))
@@ -311,7 +318,7 @@ public void should_return_response_200_with_simple_url()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -320,8 +327,9 @@ public void should_return_response_200_with_simple_url()
.BDDfy();
}
- [Fact]
- public void Bug()
+ [Fact]
+ [Trait("Bug", "134")]
+ public void should_fix_issue_134()
{
var port = PortFinder.GetRandomPort();
@@ -364,7 +372,7 @@ public void Bug()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/vacancy/1"))
@@ -400,7 +408,7 @@ public void should_return_response_200_when_path_missing_forward_slash_as_first_
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -436,7 +444,7 @@ public void should_return_response_200_when_host_has_trailing_slash()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -445,18 +453,20 @@ public void should_return_response_200_when_host_has_trailing_slash()
.BDDfy();
}
- [Fact]
- public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_template_does_not()
+ [Theory]
+ [InlineData("/products")]
+ [InlineData("/products/")]
+ public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_template_does_not(string url)
{
var port = PortFinder.GetRandomPort();
-
+ var downstreamBasePath = "/products";
var configuration = new FileConfiguration
{
Routes = new List
{
new()
{
- DownstreamPathTemplate = "/products",
+ DownstreamPathTemplate = downstreamBasePath,
DownstreamScheme = "http",
DownstreamHostAndPorts = new List
{
@@ -466,21 +476,60 @@ public void should_return_ok_when_upstream_url_ends_with_forward_slash_but_templ
Port = port,
},
},
- UpstreamPathTemplate = "/products/",
+ UpstreamPathTemplate = downstreamBasePath+"/",
UpstreamHttpMethod = new List { "Get" },
},
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamBasePath, HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products"))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway(url))
.Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
.And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
.BDDfy();
}
+ [Theory]
+ [Trait("Bug", "649")]
+ [InlineData("/account/authenticate")]
+ [InlineData("/account/authenticate/")]
+ public void should_fix_issue_649(string url)
+ {
+ var port = PortFinder.GetRandomPort();
+ var baseUrl = $"http://localhost:{port}";
+ var configuration = new FileConfiguration
+ {
+ Routes = new List
+ {
+ new()
+ {
+ UpstreamPathTemplate = "/account/authenticate/",
+
+ DownstreamPathTemplate = "/authenticate",
+ DownstreamScheme = Uri.UriSchemeHttp,
+ DownstreamHostAndPorts = new()
+ {
+ new("localhost", port),
+ },
+ },
+ },
+ GlobalConfiguration =
+ {
+ BaseUrl = baseUrl,
+ },
+ };
+
+ this.Given(x => x.GivenThereIsAServiceRunningOn(baseUrl, "/authenticate", HttpStatusCode.OK, "Hello from Laura"))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway(url))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura"))
+ .BDDfy();
+ }
+
[Fact]
public void should_return_not_found_when_upstream_url_ends_with_forward_slash_but_template_does_not()
{
@@ -508,7 +557,7 @@ public void should_return_not_found_when_upstream_url_ends_with_forward_slash_bu
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/"))
@@ -516,8 +565,10 @@ public void should_return_not_found_when_upstream_url_ends_with_forward_slash_bu
.BDDfy();
}
- [Fact]
- public void should_return_not_found()
+ [Theory]
+ [InlineData("/products", "/products/{productId}", "/products/")]
+
+ public void should_return_200_found(string downstreamPathTemplate, string upstreamPathTemplate, string requestURL)
{
var port = PortFinder.GetRandomPort();
@@ -527,7 +578,7 @@ public void should_return_not_found()
{
new()
{
- DownstreamPathTemplate = "/products",
+ DownstreamPathTemplate = downstreamPathTemplate,
DownstreamScheme = "http",
DownstreamHostAndPorts = new List
{
@@ -537,17 +588,18 @@ public void should_return_not_found()
Port = port,
},
},
- UpstreamPathTemplate = "/products/{productId}",
+ UpstreamPathTemplate = upstreamPathTemplate,
UpstreamHttpMethod = new List { "Get" },
},
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/products", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamPathTemplate, HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
- .When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/"))
- .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound))
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL))
+ .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamPathTemplate))
.BDDfy();
}
@@ -578,7 +630,7 @@ public void should_return_response_200_with_complex_url()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", 200, "Some Product"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", HttpStatusCode.OK, "Some Product"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/1"))
@@ -614,7 +666,7 @@ public void should_return_response_200_with_complex_url_that_starts_with_placeho
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/23/products/1", 200, "Some Product"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/23/products/1", HttpStatusCode.OK, "Some Product"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("23/products/1"))
@@ -650,7 +702,7 @@ public void should_not_add_trailing_slash_to_downstream_url()
},
};
- this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", 200, "Some Product"))
+ this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", HttpStatusCode.OK, "Some Product"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/products/1"))
@@ -685,7 +737,7 @@ public void should_return_response_201_with_simple_url()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 201, string.Empty))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.Created, string.Empty))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.And(x => _steps.GivenThePostHasContent("postContent"))
@@ -721,7 +773,7 @@ public void should_return_response_201_with_complex_query_string()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/newThing", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/newThing", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/newThing?DeviceType=IphoneApp&Browser=moonpigIphone&BrowserString=-&CountryCode=123&DeviceName=iPhone 5 (GSM+CDMA)&OperatingSystem=iPhone OS 7.1.2&BrowserVersion=3708AdHoc&ipAddress=-"))
@@ -757,7 +809,7 @@ public void should_return_response_200_with_placeholder_for_final_url_path()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", 200, "Some Product"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/products/1", HttpStatusCode.OK, "Some Product"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/myApp1Name/api/products/1"))
@@ -766,6 +818,58 @@ public void should_return_response_200_with_placeholder_for_final_url_path()
.BDDfy();
}
+ [Theory]
+ [Trait("Bug", "748")]
+ [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test/1", "/downstream/test/1", "?p1=v1&p2=v2&something-else")]
+ [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test/", "/downstream/test/", "?p1=v1&p2=v2&something-else")]
+ [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test", "/downstream/test", "?p1=v1&p2=v2&something-else")]
+ [InlineData("/downstream/test/{everything}", "/upstream/test/{everything}", "/upstream/test123", null, null)]
+ [InlineData("/downstream/{version}/test/{everything}", "/upstream/{version}/test/{everything}", "/upstream/v1/test/123", "/downstream/v1/test/123", "?p1=v1&p2=v2&something-else")]
+ [InlineData("/downstream/{version}/test", "/upstream/{version}/test", "/upstream/v1/test", "/downstream/v1/test", "?p1=v1&p2=v2&something-else")]
+ [InlineData("/downstream/{version}/test", "/upstream/{version}/test", "/upstream/test", null, null)]
+ public void should_return_correct_downstream_when_omitting_ending_placeholder(string downstreamPathTemplate, string upstreamPathTemplate, string requestURL, string downstreamURL, string queryString)
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = GivenDefaultConfiguration(port, upstreamPathTemplate, downstreamPathTemplate);
+ this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Aly"))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL))
+ .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamURL))
+
+ // Now check the same URL but with query string
+ // Catch-All placeholder should forward any path + query string combinations to the downstream service
+ // More: https://ocelot.readthedocs.io/en/latest/features/routing.html#placeholders:~:text=This%20will%20forward%20any%20path%20%2B%20query%20string%20combinations%20to%20the%20downstream%20service%20after%20the%20path%20%2Fapi.
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL + queryString))
+ .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamURL))
+ .And(x => x.ThenTheDownstreamUrlQueryStringShouldBe(queryString))
+ .BDDfy();
+ }
+
+ [Trait("PR", "1911")]
+ [Trait("Link", "https://ocelot.readthedocs.io/en/latest/features/routing.html#catch-all-query-string")]
+ [Theory(DisplayName = "Catch All Query String should be forwarded with all query string parameters with(out) last slash")]
+ [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts", "/apipath/contracts", "")]
+ [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts?", "/apipath/contracts", "")]
+ [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts?p1=v1&p2=v2", "/apipath/contracts", "?p1=v1&p2=v2")]
+ [InlineData("/apipath/contracts/?{everything}", "/contracts/?{everything}", "/contracts/?", "/apipath/contracts/", "")]
+ [InlineData("/apipath/contracts/?{everything}", "/contracts/?{everything}", "/contracts/?p3=v3&p4=v4", "/apipath/contracts/", "?p3=v3&p4=v4")]
+ [InlineData("/apipath/contracts?{everything}", "/contracts?{everything}", "/contracts?filter=(-something+123+else)", "/apipath/contracts", "?filter=(-something%20123%20else)")]
+ public void Should_forward_Catch_All_query_string_when_last_slash(string downstream, string upstream, string requestURL, string downstreamPath, string queryString)
+ {
+ var port = PortFinder.GetRandomPort();
+ var configuration = GivenDefaultConfiguration(port, upstream, downstream);
+ this.Given(x => GivenThereIsAServiceRunningOn($"http://localhost:{port}", downstreamPath, HttpStatusCode.OK, "Hello from Raman"))
+ .And(x => _steps.GivenThereIsAConfiguration(configuration))
+ .And(x => _steps.GivenOcelotIsRunning())
+ .When(x => _steps.WhenIGetUrlOnTheApiGateway(requestURL))
+ .Then(x => ThenTheDownstreamUrlPathShouldBe(downstreamPath)) // !
+ .And(x => x.ThenTheDownstreamUrlQueryStringShouldBe(queryString)) // !!
+ .And(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK))
+ .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Raman"))
+ .BDDfy();
+ }
+
[Fact]
public void should_return_response_201_with_simple_url_and_multiple_upstream_http_method()
{
@@ -793,7 +897,7 @@ public void should_return_response_201_with_simple_url_and_multiple_upstream_htt
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", string.Empty, 201, string.Empty))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", string.Empty, HttpStatusCode.Created, string.Empty))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.And(x => _steps.GivenThePostHasContent("postContent"))
@@ -829,7 +933,7 @@ public void should_return_response_200_with_simple_url_and_any_upstream_http_met
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/"))
@@ -882,7 +986,7 @@ public void should_return_404_when_calling_upstream_route_with_no_matching_downs
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/v1/vacancy/1", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("api/vacancy/1"))
@@ -917,7 +1021,7 @@ public void should_not_set_trailing_slash_on_url_template()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/swagger/lib/backbone-min.js", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/api/swagger/lib/backbone-min.js", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/platform/swagger/lib/backbone-min.js"))
@@ -970,7 +1074,7 @@ public void should_use_priority()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/goods/delete", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/goods/delete", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/goods/delete"))
@@ -1006,7 +1110,7 @@ public void should_match_multiple_paths_with_catch_all()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test/toot", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/test/toot", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/test/toot"))
@@ -1057,7 +1161,7 @@ public void should_fix_issue_271()
},
};
- this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/modules/Test", 200, "Hello from Laura"))
+ this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}/", "/api/v1/modules/Test", HttpStatusCode.OK, "Hello from Laura"))
.And(x => _steps.GivenThereIsAConfiguration(configuration))
.And(x => _steps.GivenOcelotIsRunning())
.When(x => _steps.WhenIGetUrlOnTheApiGateway("/api/v1/modules/Test"))
@@ -1066,20 +1170,21 @@ public void should_fix_issue_271()
.BDDfy();
}
- private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, int statusCode, string responseBody)
+ private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, HttpStatusCode statusCode, string responseBody)
{
_serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context =>
{
- _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value;
+ _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value + context.Request.Path.Value : context.Request.Path.Value;
+ _downstreamQuery = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : string.Empty;
if (_downstreamPath != basePath)
{
- context.Response.StatusCode = statusCode;
+ context.Response.StatusCode = (int)statusCode;
await context.Response.WriteAsync("downstream path didnt match base path");
}
else
{
- context.Response.StatusCode = statusCode;
+ context.Response.StatusCode = (int)statusCode;
await context.Response.WriteAsync(responseBody);
}
});
@@ -1089,11 +1194,28 @@ internal void ThenTheDownstreamUrlPathShouldBe(string expectedDownstreamPath)
{
_downstreamPath.ShouldBe(expectedDownstreamPath);
}
-
- public void Dispose()
+
+ internal void ThenTheDownstreamUrlQueryStringShouldBe(string expectedQueryString)
{
- _serviceHandler.Dispose();
- _steps.Dispose();
- }
+ _downstreamQuery.ShouldBe(expectedQueryString);
+ }
+
+ private FileConfiguration GivenDefaultConfiguration(int port, string upstream, string downstream) => new()
+ {
+ Routes = new()
+ {
+ new()
+ {
+ DownstreamPathTemplate = downstream,
+ DownstreamScheme = Uri.UriSchemeHttp,
+ DownstreamHostAndPorts =
+ {
+ new("localhost", port),
+ },
+ UpstreamPathTemplate = upstream,
+ UpstreamHttpMethod = [HttpMethods.Get],
+ },
+ },
+ };
}
}
diff --git a/test/Ocelot.AcceptanceTests/Steps.cs b/test/Ocelot.AcceptanceTests/Steps.cs
index d87391b32..e6265b93c 100644
--- a/test/Ocelot.AcceptanceTests/Steps.cs
+++ b/test/Ocelot.AcceptanceTests/Steps.cs
@@ -22,7 +22,6 @@
using Ocelot.Provider.Consul;
using Ocelot.Provider.Eureka;
using Ocelot.Provider.Polly;
-using Ocelot.Requester;
using Ocelot.ServiceDiscovery.Providers;
using Ocelot.Tracing.Butterfly;
using Ocelot.Tracing.OpenTracing;
@@ -40,15 +39,15 @@ namespace Ocelot.AcceptanceTests;
public class Steps : IDisposable
{
- private TestServer _ocelotServer;
- private HttpClient _ocelotClient;
+ protected TestServer _ocelotServer;
+ protected HttpClient _ocelotClient;
private HttpResponseMessage _response;
private HttpContent _postContent;
private BearerToken _token;
public string RequestIdKey = "OcRequestId";
private readonly Random _random;
- private readonly string _ocelotConfigFileName;
- private IWebHostBuilder _webHostBuilder;
+ protected readonly string _ocelotConfigFileName;
+ protected IWebHostBuilder _webHostBuilder;
private WebHostBuilder _ocelotBuilder;
private IWebHost _ocelotHost;
private IOcelotConfigurationChangeTokenSource _changeToken;
@@ -445,36 +444,7 @@ public void GivenOcelotIsRunningUsingJsonSerializedCache()
_ocelotClient = _ocelotServer.CreateClient();
}
- public void GivenOcelotIsRunningWithFakeHttpClientCache(IHttpClientCache cache)
- {
- _webHostBuilder = new WebHostBuilder();
-
- _webHostBuilder
- .ConfigureAppConfiguration((hostingContext, config) =>
- {
- config.SetBasePath(hostingContext.HostingEnvironment.ContentRootPath);
- var env = hostingContext.HostingEnvironment;
- config.AddJsonFile("appsettings.json", true, false)
- .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true, false);
- config.AddJsonFile(_ocelotConfigFileName, false, false);
- config.AddEnvironmentVariables();
- })
- .ConfigureServices(s =>
- {
- s.AddSingleton(cache);
- s.AddOcelot();
- })
- .Configure(app => { app.UseOcelot().Wait(); });
-
- _ocelotServer = new TestServer(_webHostBuilder);
-
- _ocelotClient = _ocelotServer.CreateClient();
- }
-
- internal void GivenIWait(int wait)
- {
- Thread.Sleep(wait);
- }
+ internal void GivenIWait(int wait) => Thread.Sleep(wait);
public void GivenOcelotIsRunningWithMiddlewareBeforePipeline(Func