diff --git a/docs/features/configuration.rst b/docs/features/configuration.rst index 931e4f58d0..87198ea1d5 100644 --- a/docs/features/configuration.rst +++ b/docs/features/configuration.rst @@ -19,9 +19,11 @@ Here is an example Route configuration. You don't need to set all of these thing .. code-block:: json { - "DownstreamPathTemplate": "/", "UpstreamPathTemplate": "/", + "UpstreamHeaderTemplates": {}, // dictionary + "UpstreamHost": "", "UpstreamHttpMethod": [ "Get" ], + "DownstreamPathTemplate": "/", "DownstreamHttpMethod": "", "DownstreamHttpVersion": "", "AddHeadersToRequest": {}, @@ -37,7 +39,7 @@ Here is an example Route configuration. You don't need to set all of these thing "ServiceName": "", "DownstreamScheme": "http", "DownstreamHostAndPorts": [ - { "Host": "localhost", "Port": 51876 } + { "Host": "localhost", "Port": 12345 } ], "QoSOptions": { "ExceptionsAllowedBeforeBreaking": 0, @@ -70,7 +72,8 @@ Here is an example Route configuration. You don't need to set all of these thing } } -More information on how to use these options is below. +The actual Route schema for properties can be found in the C# `FileRoute `_ class. +If you're interested in learning more about how to utilize these options, read below! Multiple Environments --------------------- diff --git a/docs/features/routing.rst b/docs/features/routing.rst index 6de03d8e23..e55b33e5c9 100644 --- a/docs/features/routing.rst +++ b/docs/features/routing.rst @@ -154,6 +154,58 @@ 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. +.. _routing-upstream-headers: + +Upstream Headers [#f3]_ +----------------------- + +In addition to routing by ``UpstreamPathTemplate``, you can also define ``UpstreamHeaderTemplates``. +For a route to match, all headers specified in this dictionary object must be present in the request headers. + +.. code-block:: json + + { + // ... + "UpstreamPathTemplate": "/", + "UpstreamHttpMethod": [ "Get" ], + "UpstreamHeaderTemplates": { // dictionary + "country": "uk", // 1st header + "version": "v1" // 2nd header + } + } + +In this scenario, the route will only match if a request includes both headers with the specified values. + +Header placeholders +^^^^^^^^^^^^^^^^^^^ + +Let's explore a more intriguing scenario where placeholders can be effectively utilized within your ``UpstreamHeaderTemplates``. + +Consider the following approach using the special placeholder format ``{header:placeholdername}``: + +.. code-block:: json + + { + "DownstreamPathTemplate": "/{versionnumber}/api", // with placeholder + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { "Host": "10.0.10.1", "Port": 80 } + ], + "UpstreamPathTemplate": "/api", + "UpstreamHttpMethod": [ "Get" ], + "UpstreamHeaderTemplates": { + "version": "{header:versionnumber}" // 'header:' prefix vs placeholder + } + } + +In this scenario, the entire value of the request header "**version**" is inserted into the ``DownstreamPathTemplate``. +If necessary, a more intricate upstream header template can be specified, using placeholders such as ``version-{header:version}_country-{header:country}``. + + **Note 1**: Placeholders are not required in ``DownstreamPathTemplate``. + This scenario can be utilized to mandate a specific header regardless of its value. + + **Note 2**: Additionally, the ``UpstreamHeaderTemplates`` dictionary options are applicable for :doc:`../features/requestaggregation` as well. + Priority -------- @@ -294,7 +346,7 @@ Here are two user scenarios. .. _routing-security-options: -Security Options [#f3]_ +Security Options [#f4]_ ----------------------- Ocelot allows you to manage multiple patterns for allowed/blocked IPs using the `IPAddressRange `_ package @@ -326,7 +378,7 @@ The current patterns managed are the following: .. _routing-dynamic: -Dynamic Routing [#f4]_ +Dynamic Routing [#f5]_ ---------------------- The idea is to enable dynamic routing when using a :doc:`../features/servicediscovery` provider so you don't have to provide the Route config. @@ -336,5 +388,6 @@ 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`. +.. [#f3] ":ref:`routing-upstream-headers`" feature was proposed in `issue 360 `_, and released in version `24.0 `_. +.. [#f4] ":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. +.. [#f5] ":ref:`routing-dynamic`" feature was requested as part of `issue 340 `_. Complete reference: :ref:`sd-dynamic-routing`. diff --git a/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs b/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs index 9dc93008ee..25005bd501 100644 --- a/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs +++ b/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs @@ -40,13 +40,14 @@ public class DownstreamRouteBuilder private SecurityOptions _securityOptions; private string _downstreamHttpMethod; private Version _downstreamHttpVersion; + private Dictionary _upstreamHeaders; public DownstreamRouteBuilder() { - _downstreamAddresses = new List(); - _delegatingHandlers = new List(); - _addHeadersToDownstream = new List(); - _addHeadersToUpstream = new List(); + _downstreamAddresses = new(); + _delegatingHandlers = new(); + _addHeadersToDownstream = new(); + _addHeadersToUpstream = new(); } public DownstreamRouteBuilder WithDownstreamAddresses(List downstreamAddresses) @@ -87,7 +88,9 @@ public DownstreamRouteBuilder WithUpstreamPathTemplate(UpstreamPathTemplate inpu public DownstreamRouteBuilder WithUpstreamHttpMethod(List input) { - _upstreamHttpMethod = (input.Count == 0) ? new List() : input.Select(x => new HttpMethod(x.Trim())).ToList(); + _upstreamHttpMethod = input.Count > 0 + ? input.Select(x => new HttpMethod(x.Trim())).ToList() + : new(); return this; } @@ -259,6 +262,12 @@ public DownstreamRouteBuilder WithDownstreamHttpVersion(Version downstreamHttpVe return this; } + public DownstreamRouteBuilder WithUpstreamHeaders(Dictionary input) + { + _upstreamHeaders = input; + return this; + } + public DownstreamRoute Build() { return new DownstreamRoute( @@ -295,6 +304,7 @@ public DownstreamRoute Build() _dangerousAcceptAnyServerCertificateValidator, _securityOptions, _downstreamHttpMethod, - _downstreamHttpVersion); + _downstreamHttpVersion, + _upstreamHeaders); } } diff --git a/src/Ocelot/Configuration/Builder/RouteBuilder.cs b/src/Ocelot/Configuration/Builder/RouteBuilder.cs index 8e7614a9c5..c39062829d 100644 --- a/src/Ocelot/Configuration/Builder/RouteBuilder.cs +++ b/src/Ocelot/Configuration/Builder/RouteBuilder.cs @@ -10,7 +10,8 @@ public class RouteBuilder private string _upstreamHost; private List _downstreamRoutes; private List _downstreamRoutesConfig; - private string _aggregator; + private string _aggregator; + private IDictionary _upstreamHeaders; public RouteBuilder() { @@ -58,6 +59,12 @@ public RouteBuilder WithAggregator(string aggregator) { _aggregator = aggregator; return this; + } + + public RouteBuilder WithUpstreamHeaders(IDictionary upstreamHeaders) + { + _upstreamHeaders = upstreamHeaders; + return this; } public Route Build() @@ -68,7 +75,8 @@ public Route Build() _upstreamHttpMethod, _upstreamTemplatePattern, _upstreamHost, - _aggregator + _aggregator, + _upstreamHeaders ); } } diff --git a/src/Ocelot/Configuration/Creator/AggregatesCreator.cs b/src/Ocelot/Configuration/Creator/AggregatesCreator.cs index 9a5ae29069..24d3a58b3d 100644 --- a/src/Ocelot/Configuration/Creator/AggregatesCreator.cs +++ b/src/Ocelot/Configuration/Creator/AggregatesCreator.cs @@ -5,11 +5,13 @@ namespace Ocelot.Configuration.Creator { public class AggregatesCreator : IAggregatesCreator { - private readonly IUpstreamTemplatePatternCreator _creator; + private readonly IUpstreamTemplatePatternCreator _creator; + private readonly IUpstreamHeaderTemplatePatternCreator _headerCreator; - public AggregatesCreator(IUpstreamTemplatePatternCreator creator) + public AggregatesCreator(IUpstreamTemplatePatternCreator creator, IUpstreamHeaderTemplatePatternCreator headerCreator) { - _creator = creator; + _creator = creator; + _headerCreator = headerCreator; } public List Create(FileConfiguration fileConfiguration, List routes) @@ -35,7 +37,8 @@ private Route SetUpAggregateRoute(IEnumerable routes, FileAggregateRoute applicableRoutes.Add(downstreamRoute); } - var upstreamTemplatePattern = _creator.Create(aggregateRoute); + var upstreamTemplatePattern = _creator.Create(aggregateRoute); + var upstreamHeaderTemplates = _headerCreator.Create(aggregateRoute); var route = new RouteBuilder() .WithUpstreamHttpMethod(aggregateRoute.UpstreamHttpMethod) @@ -43,7 +46,8 @@ private Route SetUpAggregateRoute(IEnumerable routes, FileAggregateRoute .WithDownstreamRoutes(applicableRoutes) .WithAggregateRouteConfig(aggregateRoute.RouteKeysConfig) .WithUpstreamHost(aggregateRoute.UpstreamHost) - .WithAggregator(aggregateRoute.Aggregator) + .WithAggregator(aggregateRoute.Aggregator) + .WithUpstreamHeaders(upstreamHeaderTemplates) .Build(); return route; diff --git a/src/Ocelot/Configuration/Creator/IUpstreamHeaderTemplatePatternCreator.cs b/src/Ocelot/Configuration/Creator/IUpstreamHeaderTemplatePatternCreator.cs new file mode 100644 index 0000000000..d2fea80046 --- /dev/null +++ b/src/Ocelot/Configuration/Creator/IUpstreamHeaderTemplatePatternCreator.cs @@ -0,0 +1,17 @@ +using Ocelot.Configuration.File; +using Ocelot.Values; + +namespace Ocelot.Configuration.Creator; + +/// +/// Ocelot feature: Routing based on request header. +/// +public interface IUpstreamHeaderTemplatePatternCreator +{ + /// + /// Creates upstream templates based on route headers. + /// + /// The route info. + /// An object where TKey is , TValue is . + IDictionary Create(IRoute route); +} diff --git a/src/Ocelot/Configuration/Creator/RoutesCreator.cs b/src/Ocelot/Configuration/Creator/RoutesCreator.cs index 8c1f1de639..0a374268df 100644 --- a/src/Ocelot/Configuration/Creator/RoutesCreator.cs +++ b/src/Ocelot/Configuration/Creator/RoutesCreator.cs @@ -1,7 +1,7 @@ using Ocelot.Cache; using Ocelot.Configuration.Builder; using Ocelot.Configuration.File; - + namespace Ocelot.Configuration.Creator { public class RoutesCreator : IRoutesCreator @@ -10,6 +10,7 @@ public class RoutesCreator : IRoutesCreator private readonly IClaimsToThingCreator _claimsToThingCreator; private readonly IAuthenticationOptionsCreator _authOptionsCreator; private readonly IUpstreamTemplatePatternCreator _upstreamTemplatePatternCreator; + private readonly IUpstreamHeaderTemplatePatternCreator _upstreamHeaderTemplatePatternCreator; private readonly IRequestIdKeyCreator _requestIdKeyCreator; private readonly IQoSOptionsCreator _qosOptionsCreator; private readonly IRouteOptionsCreator _fileRouteOptionsCreator; @@ -37,8 +38,8 @@ public RoutesCreator( ILoadBalancerOptionsCreator loadBalancerOptionsCreator, IRouteKeyCreator routeKeyCreator, ISecurityOptionsCreator securityOptionsCreator, - IVersionCreator versionCreator - ) + IVersionCreator versionCreator, + IUpstreamHeaderTemplatePatternCreator upstreamHeaderTemplatePatternCreator) { _routeKeyCreator = routeKeyCreator; _loadBalancerOptionsCreator = loadBalancerOptionsCreator; @@ -56,6 +57,7 @@ IVersionCreator versionCreator _loadBalancerOptionsCreator = loadBalancerOptionsCreator; _securityOptionsCreator = securityOptionsCreator; _versionCreator = versionCreator; + _upstreamHeaderTemplatePatternCreator = upstreamHeaderTemplatePatternCreator; } public List Create(FileConfiguration fileConfiguration) @@ -150,13 +152,15 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf private Route SetUpRoute(FileRoute fileRoute, DownstreamRoute downstreamRoutes) { - var upstreamTemplatePattern = _upstreamTemplatePatternCreator.Create(fileRoute); + var upstreamTemplatePattern = _upstreamTemplatePatternCreator.Create(fileRoute); + var upstreamHeaderTemplates = _upstreamHeaderTemplatePatternCreator.Create(fileRoute); var route = new RouteBuilder() .WithUpstreamHttpMethod(fileRoute.UpstreamHttpMethod) .WithUpstreamPathTemplate(upstreamTemplatePattern) .WithDownstreamRoute(downstreamRoutes) - .WithUpstreamHost(fileRoute.UpstreamHost) + .WithUpstreamHost(fileRoute.UpstreamHost) + .WithUpstreamHeaders(upstreamHeaderTemplates) .Build(); return route; diff --git a/src/Ocelot/Configuration/Creator/UpstreamHeaderTemplatePatternCreator.cs b/src/Ocelot/Configuration/Creator/UpstreamHeaderTemplatePatternCreator.cs new file mode 100644 index 0000000000..52c653f5ec --- /dev/null +++ b/src/Ocelot/Configuration/Creator/UpstreamHeaderTemplatePatternCreator.cs @@ -0,0 +1,50 @@ +using Ocelot.Configuration.File; +using Ocelot.Values; + +namespace Ocelot.Configuration.Creator; + +/// +/// Default creator of upstream templates based on route headers. +/// +/// Ocelot feature: Routing based on request header. +public partial class UpstreamHeaderTemplatePatternCreator : IUpstreamHeaderTemplatePatternCreator +{ + private const string PlaceHolderPattern = @"(\{header:.*?\})"; +#if NET7_0_OR_GREATER + [GeneratedRegex(PlaceHolderPattern, RegexOptions.IgnoreCase | RegexOptions.Singleline, "en-US")] + private static partial Regex RegExPlaceholders(); +#else + private static readonly Regex RegExPlaceholdersVar = new(PlaceHolderPattern, RegexOptions.IgnoreCase | RegexOptions.Singleline, TimeSpan.FromMilliseconds(1000)); + private static Regex RegExPlaceholders() => RegExPlaceholdersVar; +#endif + + public IDictionary Create(IRoute route) + { + var result = new Dictionary(); + + foreach (var headerTemplate in route.UpstreamHeaderTemplates) + { + var headerTemplateValue = headerTemplate.Value; + var matches = RegExPlaceholders().Matches(headerTemplateValue); + + if (matches.Count > 0) + { + var placeholders = matches.Select(m => m.Groups[1].Value).ToArray(); + for (int i = 0; i < placeholders.Length; i++) + { + var indexOfPlaceholder = headerTemplateValue.IndexOf(placeholders[i]); + var placeholderName = placeholders[i][8..^1]; // remove "{header:" and "}" + headerTemplateValue = headerTemplateValue.Replace(placeholders[i], $"(?<{placeholderName}>.+)"); + } + } + + var template = route.RouteIsCaseSensitive + ? $"^{headerTemplateValue}$" + : $"^(?i){headerTemplateValue}$"; // ignore case + + result.Add(headerTemplate.Key, new(template, headerTemplate.Value)); + } + + return result; + } +} diff --git a/src/Ocelot/Configuration/DownstreamRoute.cs b/src/Ocelot/Configuration/DownstreamRoute.cs index 585c2554f5..f71563cb0c 100644 --- a/src/Ocelot/Configuration/DownstreamRoute.cs +++ b/src/Ocelot/Configuration/DownstreamRoute.cs @@ -39,7 +39,8 @@ public DownstreamRoute( bool dangerousAcceptAnyServerCertificateValidator, SecurityOptions securityOptions, string downstreamHttpMethod, - Version downstreamHttpVersion) + Version downstreamHttpVersion, + Dictionary upstreamHeaders) { DangerousAcceptAnyServerCertificateValidator = dangerousAcceptAnyServerCertificateValidator; AddHeadersToDownstream = addHeadersToDownstream; @@ -74,7 +75,8 @@ public DownstreamRoute( AddHeadersToUpstream = addHeadersToUpstream; SecurityOptions = securityOptions; DownstreamHttpMethod = downstreamHttpMethod; - DownstreamHttpVersion = downstreamHttpVersion; + DownstreamHttpVersion = downstreamHttpVersion; + UpstreamHeaders = upstreamHeaders ?? new(); } public string Key { get; } @@ -110,6 +112,7 @@ public DownstreamRoute( public bool DangerousAcceptAnyServerCertificateValidator { get; } public SecurityOptions SecurityOptions { get; } public string DownstreamHttpMethod { get; } - public Version DownstreamHttpVersion { get; } + public Version DownstreamHttpVersion { get; } + public Dictionary UpstreamHeaders { get; } } } diff --git a/src/Ocelot/Configuration/File/FileAggregateRoute.cs b/src/Ocelot/Configuration/File/FileAggregateRoute.cs index ad47d735f6..fa0ef305a7 100644 --- a/src/Ocelot/Configuration/File/FileAggregateRoute.cs +++ b/src/Ocelot/Configuration/File/FileAggregateRoute.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; + namespace Ocelot.Configuration.File { public class FileAggregateRoute : IRoute @@ -10,8 +12,15 @@ public class FileAggregateRoute : IRoute public string Aggregator { get; set; } // Only supports GET..are you crazy!! POST, PUT WOULD BE CRAZY!! :) - public List UpstreamHttpMethod => new() { "Get" }; - - public int Priority { get; set; } = 1; + public List UpstreamHttpMethod => new() { HttpMethods.Get }; + public IDictionary UpstreamHeaderTemplates { get; set; } + public int Priority { get; set; } = 1; + + public FileAggregateRoute() + { + RouteKeys = new(); + RouteKeysConfig = new(); + UpstreamHeaderTemplates = new Dictionary(); + } } } diff --git a/src/Ocelot/Configuration/File/FileRoute.cs b/src/Ocelot/Configuration/File/FileRoute.cs index 5823113ad9..840e125073 100644 --- a/src/Ocelot/Configuration/File/FileRoute.cs +++ b/src/Ocelot/Configuration/File/FileRoute.cs @@ -19,7 +19,8 @@ public FileRoute() QoSOptions = new FileQoSOptions(); RateLimitOptions = new FileRateLimitRule(); RouteClaimsRequirement = new Dictionary(); - SecurityOptions = new FileSecurityOptions(); + SecurityOptions = new FileSecurityOptions(); + UpstreamHeaderTemplates = new Dictionary(); UpstreamHeaderTransform = new Dictionary(); UpstreamHttpMethod = new List(); } @@ -60,6 +61,7 @@ public FileRoute(FileRoute from) public string UpstreamHost { get; set; } public List UpstreamHttpMethod { get; set; } public string UpstreamPathTemplate { get; set; } + public IDictionary UpstreamHeaderTemplates { get; set; } /// /// Clones this object by making a deep copy. @@ -101,6 +103,7 @@ public static void DeepCopy(FileRoute from, FileRoute to) to.ServiceName = from.ServiceName; to.ServiceNamespace = from.ServiceNamespace; to.Timeout = from.Timeout; + to.UpstreamHeaderTemplates = new Dictionary(from.UpstreamHeaderTemplates); to.UpstreamHeaderTransform = new(from.UpstreamHeaderTransform); to.UpstreamHost = from.UpstreamHost; to.UpstreamHttpMethod = new(from.UpstreamHttpMethod); diff --git a/src/Ocelot/Configuration/File/IRoute.cs b/src/Ocelot/Configuration/File/IRoute.cs index 74df79b232..1a70debb35 100644 --- a/src/Ocelot/Configuration/File/IRoute.cs +++ b/src/Ocelot/Configuration/File/IRoute.cs @@ -2,6 +2,7 @@ public interface IRoute { + IDictionary UpstreamHeaderTemplates { get; set; } string UpstreamPathTemplate { get; set; } bool RouteIsCaseSensitive { get; set; } int Priority { get; set; } diff --git a/src/Ocelot/Configuration/Route.cs b/src/Ocelot/Configuration/Route.cs index 8f9c0992f9..12c57949cc 100644 --- a/src/Ocelot/Configuration/Route.cs +++ b/src/Ocelot/Configuration/Route.cs @@ -10,7 +10,8 @@ public Route(List downstreamRoute, List upstreamHttpMethod, UpstreamPathTemplate upstreamTemplatePattern, string upstreamHost, - string aggregator) + string aggregator, + IDictionary upstreamHeaderTemplates) { UpstreamHost = upstreamHost; DownstreamRoute = downstreamRoute; @@ -18,8 +19,10 @@ public Route(List downstreamRoute, UpstreamHttpMethod = upstreamHttpMethod; UpstreamTemplatePattern = upstreamTemplatePattern; Aggregator = aggregator; + UpstreamHeaderTemplates = upstreamHeaderTemplates; } + public IDictionary UpstreamHeaderTemplates { get; } public UpstreamPathTemplate UpstreamTemplatePattern { get; } public List UpstreamHttpMethod { get; } public string UpstreamHost { get; } diff --git a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs index 7e74251e23..c8596b2d55 100644 --- a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs @@ -123,15 +123,15 @@ private static bool DoesNotContainRoutesWithSpecificRequestIdKeys(FileAggregateR return routesForAggregate.All(r => string.IsNullOrEmpty(r.RequestIdKey)); } - private static bool IsNotDuplicateIn(FileRoute route, - IEnumerable routes) + private static bool IsNotDuplicateIn(FileRoute route, IEnumerable routes) { var matchingRoutes = routes .Where(r => r.UpstreamPathTemplate == route.UpstreamPathTemplate - && r.UpstreamHost == route.UpstreamHost) - .ToList(); + && r.UpstreamHost == route.UpstreamHost + && AreTheSame(r.UpstreamHeaderTemplates, route.UpstreamHeaderTemplates)) + .ToArray(); - if (matchingRoutes.Count == 1) + if (matchingRoutes.Length == 1) { return true; } @@ -150,7 +150,11 @@ private static bool IsNotDuplicateIn(FileRoute route, } return true; - } + } + + private static bool AreTheSame(IDictionary upstreamHeaderTemplates, IDictionary otherHeaderTemplates) + => upstreamHeaderTemplates.Count == otherHeaderTemplates.Count && + upstreamHeaderTemplates.All(x => otherHeaderTemplates.ContainsKey(x.Key) && otherHeaderTemplates[x.Key] == x.Value); private static bool IsNotDuplicateIn(FileRoute route, IEnumerable aggregateRoutes) diff --git a/src/Ocelot/DependencyInjection/Features.cs b/src/Ocelot/DependencyInjection/Features.cs new file mode 100644 index 0000000000..b1abdd9b53 --- /dev/null +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Ocelot.Configuration.Creator; +using Ocelot.DownstreamRouteFinder.HeaderMatcher; + +namespace Ocelot.DependencyInjection; + +public static class Features +{ + /// + /// Ocelot feature: Routing based on request header. + /// + /// The services collection to add the feature to. + /// The same object. + public static IServiceCollection AddHeaderRouting(this IServiceCollection services) => services + .AddSingleton() + .AddSingleton() + .AddSingleton(); +} diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index a72ec3cbfa..7874fbf13a 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -144,6 +144,9 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); + // Features + Services.AddHeaderRouting(); + // Add ASP.NET services var assembly = typeof(FileConfigurationController).GetTypeInfo().Assembly; MvcCoreBuilder = (customBuilder ?? AddDefaultAspNetServices) diff --git a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteCreator.cs b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteCreator.cs index be4d5e32bc..ef2590ae5d 100644 --- a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteCreator.cs +++ b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteCreator.cs @@ -18,7 +18,8 @@ public DownstreamRouteCreator(IQoSOptionsCreator qoSOptionsCreator) _cache = new ConcurrentDictionary>(); } - public Response Get(string upstreamUrlPath, string upstreamQueryString, string upstreamHttpMethod, IInternalConfiguration configuration, string upstreamHost) + public Response Get(string upstreamUrlPath, string upstreamQueryString, string upstreamHttpMethod, + IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders) { var serviceName = GetServiceName(upstreamUrlPath); @@ -69,7 +70,7 @@ public Response Get(string upstreamUrlPath, string upstre var route = new RouteBuilder() .WithDownstreamRoute(downstreamRoute) .WithUpstreamHttpMethod(new List { upstreamHttpMethod }) - .WithUpstreamPathTemplate(upstreamPathTemplate) + .WithUpstreamPathTemplate(upstreamPathTemplate) .Build(); downstreamRouteHolder = new OkResponse(new DownstreamRouteHolder(new List(), route)); diff --git a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs index 59cf1f7b7c..fab94d4e1d 100644 --- a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs +++ b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs @@ -1,21 +1,31 @@ using Ocelot.Configuration; +using Ocelot.DownstreamRouteFinder.HeaderMatcher; using Ocelot.DownstreamRouteFinder.UrlMatcher; -using Ocelot.Responses; +using Ocelot.Responses; namespace Ocelot.DownstreamRouteFinder.Finder { public class DownstreamRouteFinder : IDownstreamRouteProvider { private readonly IUrlPathToUrlTemplateMatcher _urlMatcher; - private readonly IPlaceholderNameAndValueFinder _placeholderNameAndValueFinder; + private readonly IPlaceholderNameAndValueFinder _pathPlaceholderFinder; + private readonly IHeadersToHeaderTemplatesMatcher _headerMatcher; + private readonly IHeaderPlaceholderNameAndValueFinder _headerPlaceholderFinder; - public DownstreamRouteFinder(IUrlPathToUrlTemplateMatcher urlMatcher, IPlaceholderNameAndValueFinder urlPathPlaceholderNameAndValueFinder) + public DownstreamRouteFinder( + IUrlPathToUrlTemplateMatcher urlMatcher, + IPlaceholderNameAndValueFinder pathPlaceholderFinder, + IHeadersToHeaderTemplatesMatcher headerMatcher, + IHeaderPlaceholderNameAndValueFinder headerPlaceholderFinder) { _urlMatcher = urlMatcher; - _placeholderNameAndValueFinder = urlPathPlaceholderNameAndValueFinder; + _pathPlaceholderFinder = pathPlaceholderFinder; + _headerMatcher = headerMatcher; + _headerPlaceholderFinder = headerPlaceholderFinder; } - public Response Get(string upstreamUrlPath, string upstreamQueryString, string httpMethod, IInternalConfiguration configuration, string upstreamHost) + public Response Get(string upstreamUrlPath, string upstreamQueryString, string httpMethod, + IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders) { var downstreamRoutes = new List(); @@ -25,20 +35,20 @@ public Response Get(string upstreamUrlPath, string upstre foreach (var route in applicableRoutes) { - var urlMatch = _urlMatcher.Match(upstreamUrlPath, upstreamQueryString, route.UpstreamTemplatePattern); + var urlMatch = _urlMatcher.Match(upstreamUrlPath, upstreamQueryString, route.UpstreamTemplatePattern); + var headersMatch = _headerMatcher.Match(upstreamHeaders, route.UpstreamHeaderTemplates); - if (urlMatch.Data.Match) + if (urlMatch.Data.Match && headersMatch) { - downstreamRoutes.Add(GetPlaceholderNamesAndValues(upstreamUrlPath, upstreamQueryString, route)); + downstreamRoutes.Add(GetPlaceholderNamesAndValues(upstreamUrlPath, upstreamQueryString, route, upstreamHeaders)); } - } - - if (downstreamRoutes.Any()) + } + + if (downstreamRoutes.Count != 0) { var notNullOption = downstreamRoutes.FirstOrDefault(x => !string.IsNullOrEmpty(x.Route.UpstreamHost)); - var nullOption = downstreamRoutes.FirstOrDefault(x => string.IsNullOrEmpty(x.Route.UpstreamHost)); - - return notNullOption != null ? new OkResponse(notNullOption) : new OkResponse(nullOption); + var nullOption = downstreamRoutes.FirstOrDefault(x => string.IsNullOrEmpty(x.Route.UpstreamHost)); + return new OkResponse(notNullOption ?? nullOption); } return new ErrorResponse(new UnableToFindDownstreamRouteError(upstreamUrlPath, httpMethod)); @@ -50,11 +60,15 @@ private static bool RouteIsApplicableToThisRequest(Route route, string httpMetho (string.IsNullOrEmpty(route.UpstreamHost) || route.UpstreamHost == upstreamHost); } - private DownstreamRouteHolder GetPlaceholderNamesAndValues(string path, string query, Route route) + private DownstreamRouteHolder GetPlaceholderNamesAndValues(string path, string query, Route route, IDictionary upstreamHeaders) { - var templatePlaceholderNameAndValues = _placeholderNameAndValueFinder.Find(path, query, route.UpstreamTemplatePattern.OriginalValue); + var templatePlaceholderNameAndValues = _pathPlaceholderFinder + .Find(path, query, route.UpstreamTemplatePattern.OriginalValue) + .Data; + var headerPlaceholders = _headerPlaceholderFinder.Find(upstreamHeaders, route.UpstreamHeaderTemplates); + templatePlaceholderNameAndValues.AddRange(headerPlaceholders); - return new DownstreamRouteHolder(templatePlaceholderNameAndValues.Data, route); + return new DownstreamRouteHolder(templatePlaceholderNameAndValues, route); } } } diff --git a/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs b/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs index ed2a657ef1..c30ba31bc7 100644 --- a/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs +++ b/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs @@ -1,10 +1,10 @@ using Ocelot.Configuration; using Ocelot.Responses; + +namespace Ocelot.DownstreamRouteFinder.Finder; -namespace Ocelot.DownstreamRouteFinder.Finder +public interface IDownstreamRouteProvider { - public interface IDownstreamRouteProvider - { - Response Get(string upstreamUrlPath, string upstreamQueryString, string upstreamHttpMethod, IInternalConfiguration configuration, string upstreamHost); - } + Response Get(string upstreamUrlPath, string upstreamQueryString, string upstreamHttpMethod, + IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders); } diff --git a/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/HeaderPlaceholderNameAndValueFinder.cs b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/HeaderPlaceholderNameAndValueFinder.cs new file mode 100644 index 0000000000..56e55b2f4d --- /dev/null +++ b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/HeaderPlaceholderNameAndValueFinder.cs @@ -0,0 +1,24 @@ +using Ocelot.DownstreamRouteFinder.UrlMatcher; +using Ocelot.Values; + +namespace Ocelot.DownstreamRouteFinder.HeaderMatcher; + +public class HeaderPlaceholderNameAndValueFinder : IHeaderPlaceholderNameAndValueFinder +{ + public IList Find(IDictionary upstreamHeaders, IDictionary templateHeaders) + { + var result = new List(); + foreach (var templateHeader in templateHeaders) + { + var upstreamHeader = upstreamHeaders[templateHeader.Key]; + var matches = templateHeader.Value.Pattern.Matches(upstreamHeader); + var placeholders = matches + .SelectMany(g => g.Groups as IEnumerable) + .Where(g => g.Name != "0") + .Select(g => new PlaceholderNameAndValue(string.Concat('{', g.Name, '}'), g.Value)); + result.AddRange(placeholders); + } + + return result; + } +} diff --git a/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/HeadersToHeaderTemplatesMatcher.cs b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/HeadersToHeaderTemplatesMatcher.cs new file mode 100644 index 0000000000..42be8dc7f6 --- /dev/null +++ b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/HeadersToHeaderTemplatesMatcher.cs @@ -0,0 +1,11 @@ +using Ocelot.Values; + +namespace Ocelot.DownstreamRouteFinder.HeaderMatcher; + +public class HeadersToHeaderTemplatesMatcher : IHeadersToHeaderTemplatesMatcher +{ + public bool Match(IDictionary upstreamHeaders, IDictionary routeHeaders) => + routeHeaders == null || + upstreamHeaders != null + && routeHeaders.All(h => upstreamHeaders.ContainsKey(h.Key) && routeHeaders[h.Key].Pattern.IsMatch(upstreamHeaders[h.Key])); +} diff --git a/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/IHeaderPlaceholderNameAndValueFinder.cs b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/IHeaderPlaceholderNameAndValueFinder.cs new file mode 100644 index 0000000000..6f641d278b --- /dev/null +++ b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/IHeaderPlaceholderNameAndValueFinder.cs @@ -0,0 +1,12 @@ +using Ocelot.DownstreamRouteFinder.UrlMatcher; +using Ocelot.Values; + +namespace Ocelot.DownstreamRouteFinder.HeaderMatcher; + +/// +/// Ocelot feature: Routing based on request header. +/// +public interface IHeaderPlaceholderNameAndValueFinder +{ + IList Find(IDictionary upstreamHeaders, IDictionary templateHeaders); +} diff --git a/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/IHeadersToHeaderTemplatesMatcher.cs b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/IHeadersToHeaderTemplatesMatcher.cs new file mode 100644 index 0000000000..37dcea32ac --- /dev/null +++ b/src/Ocelot/DownstreamRouteFinder/HeaderMatcher/IHeadersToHeaderTemplatesMatcher.cs @@ -0,0 +1,11 @@ +using Ocelot.Values; + +namespace Ocelot.DownstreamRouteFinder.HeaderMatcher; + +/// +/// Ocelot feature: Routing based on request header. +/// +public interface IHeadersToHeaderTemplatesMatcher +{ + bool Match(IDictionary upstreamHeaders, IDictionary routeHeaders); +} diff --git a/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs b/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs index 63c21b76cf..38ab1bd36a 100644 --- a/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs +++ b/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs @@ -24,26 +24,22 @@ IDownstreamRouteProviderFactory downstreamRouteFinder public async Task Invoke(HttpContext httpContext) { var upstreamUrlPath = httpContext.Request.Path.ToString(); - var upstreamQueryString = httpContext.Request.QueryString.ToString(); - - var hostHeader = httpContext.Request.Headers["Host"].ToString(); + var internalConfiguration = httpContext.Items.IInternalConfiguration(); + var hostHeader = httpContext.Request.Headers.Host.ToString(); var upstreamHost = hostHeader.Contains(':') ? hostHeader.Split(':')[0] : hostHeader; + var upstreamHeaders = httpContext.Request.Headers + .ToDictionary(h => h.Key, h => string.Join(';', h.Value)); - Logger.LogDebug(() => $"Upstream url path is {upstreamUrlPath}"); - - var internalConfiguration = httpContext.Items.IInternalConfiguration(); + Logger.LogDebug(() => $"Upstream URL path is '{upstreamUrlPath}'."); var provider = _factory.Get(internalConfiguration); - - var response = provider.Get(upstreamUrlPath, upstreamQueryString, httpContext.Request.Method, internalConfiguration, upstreamHost); - + var response = provider.Get(upstreamUrlPath, upstreamQueryString, httpContext.Request.Method, internalConfiguration, upstreamHost, upstreamHeaders); if (response.IsError) { Logger.LogWarning(() => $"{MiddlewareName} setting pipeline errors. IDownstreamRouteFinder returned {response.Errors.ToErrorString()}"); - httpContext.Items.UpsertErrors(response.Errors); return; } @@ -52,7 +48,6 @@ public async Task Invoke(HttpContext httpContext) // why set both of these on HttpContext httpContext.Items.UpsertTemplatePlaceholderNameAndValues(response.Data.TemplatePlaceholderNameAndValues); - httpContext.Items.UpsertDownstreamRoute(response.Data); await _next.Invoke(httpContext); diff --git a/src/Ocelot/Values/UpstreamHeaderTemplate.cs b/src/Ocelot/Values/UpstreamHeaderTemplate.cs new file mode 100644 index 0000000000..3151fbdf8a --- /dev/null +++ b/src/Ocelot/Values/UpstreamHeaderTemplate.cs @@ -0,0 +1,19 @@ +namespace Ocelot.Values; + +/// +/// Upstream template properties of headers and their regular expression. +/// +/// Ocelot feature: Routing based on request header. +public class UpstreamHeaderTemplate +{ + public string Template { get; } + public string OriginalValue { get; } + public Regex Pattern { get; } + + public UpstreamHeaderTemplate(string template, string originalValue) + { + Template = template; + OriginalValue = originalValue; + Pattern = new Regex(template ?? "$^", RegexOptions.Compiled | RegexOptions.Singleline); + } +} diff --git a/test/Ocelot.AcceptanceTests/Routing/RoutingBasedOnHeadersTests.cs b/test/Ocelot.AcceptanceTests/Routing/RoutingBasedOnHeadersTests.cs new file mode 100644 index 0000000000..eea54640f7 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/Routing/RoutingBasedOnHeadersTests.cs @@ -0,0 +1,464 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration.File; + +namespace Ocelot.AcceptanceTests.Routing; + +[Trait("PR", "1312")] +[Trait("Feat", "360")] +public sealed class RoutingBasedOnHeadersTests : Steps, IDisposable +{ + private string _downstreamPath; + private readonly ServiceHandler _serviceHandler; + + public RoutingBasedOnHeadersTests() + { + _serviceHandler = new(); + } + + public override void Dispose() + { + _serviceHandler.Dispose(); + base.Dispose(); + } + + [Fact] + public void Should_match_one_header_value() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue = "PL"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName] = headerValue, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, headerValue)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello())) + .BDDfy(); + } + + [Fact] + public void Should_match_one_header_value_when_more_headers() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue = "PL"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName] = headerValue, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader("other", "otherValue")) + .And(x => GivenIAddAHeader(headerName, headerValue)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello())) + .BDDfy(); + } + + [Fact] + public void Should_match_two_header_values_when_more_headers() + { + var port = PortFinder.GetRandomPort(); + var headerName1 = "country_code"; + var headerValue1 = "PL"; + var headerName2 = "region"; + var headerValue2 = "MAZ"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName1] = headerValue1, + [headerName2] = headerValue2, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName1, headerValue1)) + .And(x => GivenIAddAHeader("other", "otherValue")) + .And(x => GivenIAddAHeader(headerName2, headerValue2)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello())) + .BDDfy(); + } + + [Fact] + public void Should_not_match_one_header_value() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue = "PL"; + var anotherHeaderValue = "UK"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName] = headerValue, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, anotherHeaderValue)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_not_match_one_header_value_when_no_headers() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue = "PL"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName] = headerValue, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_not_match_two_header_values_when_one_different() + { + var port = PortFinder.GetRandomPort(); + var headerName1 = "country_code"; + var headerValue1 = "PL"; + var headerName2 = "region"; + var headerValue2 = "MAZ"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName1] = headerValue1, + [headerName2] = headerValue2, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName1, headerValue1)) + .And(x => GivenIAddAHeader("other", "otherValue")) + .And(x => GivenIAddAHeader(headerName2, "anothervalue")) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_not_match_two_header_values_when_one_not_existing() + { + var port = PortFinder.GetRandomPort(); + var headerName1 = "country_code"; + var headerValue1 = "PL"; + var headerName2 = "region"; + var headerValue2 = "MAZ"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName1] = headerValue1, + [headerName2] = headerValue2, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName1, headerValue1)) + .And(x => GivenIAddAHeader("other", "otherValue")) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_not_match_one_header_value_when_header_duplicated() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue = "PL"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, new() + { + [headerName] = headerValue, + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, headerValue)) + .And(x => GivenIAddAHeader(headerName, "othervalue")) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_aggregated_route_match_header_value() + { + var port1 = PortFinder.GetRandomPort(); + var port2 = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue = "PL"; + var routeA = GivenRoute(port1, "/a", "Laura"); + var routeB = GivenRoute(port2, "/b", "Tom"); + var route = GivenAggRouteWithUpstreamHeaderTemplates(new() + { + [headerName] = headerValue, + }); + var configuration = GivenConfiguration(routeA, routeB); + configuration.Aggregates.Add(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port1, "/a", HttpStatusCode.OK, Hello("Laura"))) + .And(x => GivenThereIsAServiceRunningOn(port2, "/b", HttpStatusCode.OK, Hello("Tom"))) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, headerValue)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .BDDfy(); + } + + [Fact] + public void Should_aggregated_route_not_match_header_value() + { + var port1 = PortFinder.GetRandomPort(); + var port2 = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue = "PL"; + var routeA = GivenRoute(port1, "/a", "Laura"); + var routeB = GivenRoute(port2, "/b", "Tom"); + var route = GivenAggRouteWithUpstreamHeaderTemplates(new() + { + [headerName] = headerValue, + }); + var configuration = GivenConfiguration(routeA, routeB); + configuration.Aggregates.Add(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port1, "/a", HttpStatusCode.OK, Hello("Laura"))) + .And(x => x.GivenThereIsAServiceRunningOn(port2, "/b", HttpStatusCode.OK, Hello("Tom"))) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_match_header_placeholder() + { + var port = PortFinder.GetRandomPort(); + var headerName = "Region"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, "/products", "/api.internal-{code}/products", + new() + { + [headerName] = "{header:code}", + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port, "/api.internal-uk/products", HttpStatusCode.OK, Hello("UK"))) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, "uk")) + .When(x => WhenIGetUrlOnTheApiGateway("/products")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello("UK"))) + .BDDfy(); + } + + [Fact] + public void Should_match_header_placeholder_not_in_downstream_path() + { + var port = PortFinder.GetRandomPort(); + var headerName = "ProductName"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, "/products", "/products-info", + new() + { + [headerName] = "product-{header:everything}", + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port, "/products-info", HttpStatusCode.OK, Hello("products"))) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, "product-Camera")) + .When(x => WhenIGetUrlOnTheApiGateway("/products")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello("products"))) + .BDDfy(); + } + + [Fact] + public void Should_distinguish_route_for_different_roles() + { + var port = PortFinder.GetRandomPort(); + var headerName = "Origin"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, "/products", "/products-admin", + new() + { + [headerName] = "admin.xxx.com", + }); + var route2 = GivenRouteWithUpstreamHeaderTemplates(port, "/products", "/products", null); + var configuration = GivenConfiguration(route, route2); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port, "/products-admin", HttpStatusCode.OK, Hello("products admin"))) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, "admin.xxx.com")) + .When(x => WhenIGetUrlOnTheApiGateway("/products")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello("products admin"))) + .BDDfy(); + } + + [Fact] + public void Should_match_header_and_url_placeholders() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, "/{aa}", "/{country_code}/{version}/{aa}", + new() + { + [headerName] = "start_{header:country_code}_version_{header:version}_end", + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port, "/pl/v1/bb", HttpStatusCode.OK, Hello())) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, "start_pl_version_v1_end")) + .When(x => WhenIGetUrlOnTheApiGateway("/bb")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello())) + .BDDfy(); + } + + [Fact] + public void Should_match_header_with_braces() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, "/", "/aa", + new() + { + [headerName] = "my_{header}", + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port, "/aa", HttpStatusCode.OK, Hello())) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, "my_{header}")) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello())) + .BDDfy(); + } + + [Fact] + public void Should_match_two_headers_with_the_same_name() + { + var port = PortFinder.GetRandomPort(); + var headerName = "country_code"; + var headerValue1 = "PL"; + var headerValue2 = "UK"; + var route = GivenRouteWithUpstreamHeaderTemplates(port, + new() + { + [headerName] = headerValue1 + ";{header:whatever}", + }); + var configuration = GivenConfiguration(route); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .And(x => GivenIAddAHeader(headerName, headerValue1)) + .And(x => GivenIAddAHeader(headerName, headerValue2)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => ThenTheResponseBodyShouldBe(Hello())) + .BDDfy(); + } + + private static string Hello() => Hello("Jolanta"); + private static string Hello(string who) => $"Hello from {who}"; + + private void GivenThereIsAServiceRunningOn(int port) + => GivenThereIsAServiceRunningOn(port, "/", HttpStatusCode.OK, Hello()); + + private void GivenThereIsAServiceRunningOn(int port, string basePath, HttpStatusCode statusCode, string responseBody) + { + basePath ??= "/"; + responseBody ??= Hello(); + var baseUrl = DownstreamUrl(port); + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + { + _downstreamPath = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + + if (_downstreamPath != basePath) + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + await context.Response.WriteAsync($"{nameof(_downstreamPath)} is not equal to {nameof(basePath)}"); + } + else + { + context.Response.StatusCode = (int)statusCode; + await context.Response.WriteAsync(responseBody); + } + }); + } + + private void ThenTheDownstreamUrlPathShouldBe(string expected) => _downstreamPath.ShouldBe(expected); + + private static FileRoute GivenRoute(int port, string path = null, string key = null) => new() + { + DownstreamPathTemplate = path ?? "/", + DownstreamScheme = "http", + DownstreamHostAndPorts = new() + { + new("localhost", port), + }, + UpstreamPathTemplate = path ?? "/", + UpstreamHttpMethod = new() { HttpMethods.Get }, + Key = key, + }; + + private static FileRoute GivenRouteWithUpstreamHeaderTemplates(int port, Dictionary templates) + { + var route = GivenRoute(port); + route.UpstreamHeaderTemplates = templates; + return route; + } + + private static FileRoute GivenRouteWithUpstreamHeaderTemplates(int port, string upstream, string downstream, Dictionary templates) + { + var route = GivenRoute(port); + route.UpstreamHeaderTemplates = templates; + route.UpstreamPathTemplate = upstream ?? "/"; + route.DownstreamPathTemplate = downstream ?? "/"; + return route; + } + + private static FileAggregateRoute GivenAggRouteWithUpstreamHeaderTemplates(Dictionary templates) => new() + { + UpstreamPathTemplate = "/", + UpstreamHost = "localhost", + RouteKeys = new() { "Laura", "Tom" }, + UpstreamHeaderTemplates = templates, + }; +} diff --git a/test/Ocelot.AcceptanceTests/RoutingTests.cs b/test/Ocelot.AcceptanceTests/Routing/RoutingTests.cs similarity index 97% rename from test/Ocelot.AcceptanceTests/RoutingTests.cs rename to test/Ocelot.AcceptanceTests/Routing/RoutingTests.cs index b881a831f9..388e690d13 100644 --- a/test/Ocelot.AcceptanceTests/RoutingTests.cs +++ b/test/Ocelot.AcceptanceTests/Routing/RoutingTests.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using Ocelot.Configuration.File; -namespace Ocelot.AcceptanceTests +namespace Ocelot.AcceptanceTests.Routing { public sealed class RoutingTests : IDisposable { diff --git a/test/Ocelot.AcceptanceTests/RoutingWithQueryStringTests.cs b/test/Ocelot.AcceptanceTests/Routing/RoutingWithQueryStringTests.cs similarity index 97% rename from test/Ocelot.AcceptanceTests/RoutingWithQueryStringTests.cs rename to test/Ocelot.AcceptanceTests/Routing/RoutingWithQueryStringTests.cs index 5c34167aca..ad4a16ae51 100644 --- a/test/Ocelot.AcceptanceTests/RoutingWithQueryStringTests.cs +++ b/test/Ocelot.AcceptanceTests/Routing/RoutingWithQueryStringTests.cs @@ -1,379 +1,379 @@ using Microsoft.AspNetCore.Http; using Ocelot.Configuration.File; -namespace Ocelot.AcceptanceTests -{ - public class RoutingWithQueryStringTests : IDisposable - { - private readonly Steps _steps; - private readonly ServiceHandler _serviceHandler; - - public RoutingWithQueryStringTests() - { - _serviceHandler = new ServiceHandler(); - _steps = new Steps(); - } - - [Fact] - public void Should_return_response_200_with_query_string_template() - { - var subscriptionId = Guid.NewGuid().ToString(); - var unitId = Guid.NewGuid().ToString(); - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/subscriptions/{subscriptionId}/updates", $"?unitId={unitId}", "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/units/{subscriptionId}/{unitId}/updates")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } +namespace Ocelot.AcceptanceTests.Routing +{ + public class RoutingWithQueryStringTests : IDisposable + { + private readonly Steps _steps; + private readonly ServiceHandler _serviceHandler; + + public RoutingWithQueryStringTests() + { + _serviceHandler = new ServiceHandler(); + _steps = new Steps(); + } + + [Fact] + public void Should_return_response_200_with_query_string_template() + { + var subscriptionId = Guid.NewGuid().ToString(); + var unitId = Guid.NewGuid().ToString(); + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = port, + }, + }, + UpstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/subscriptions/{subscriptionId}/updates", $"?unitId={unitId}", "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/units/{subscriptionId}/{unitId}/updates")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } [Theory(DisplayName = "1182: " + nameof(Should_return_200_with_query_string_template_different_keys))] - [InlineData("")] - [InlineData("&x=xxx")] - public void Should_return_200_with_query_string_template_different_keys(string additionalParams) + [InlineData("")] + [InlineData("&x=xxx")] + public void Should_return_200_with_query_string_template_different_keys(string additionalParams) { - var subscriptionId = Guid.NewGuid().ToString(); - var unitId = Guid.NewGuid().ToString(); - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unit}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/api/units/{subscriptionId}/updates?unit={unit}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/subscriptions/{subscriptionId}/updates", $"?unitId={unitId}{additionalParams}", "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/units/{subscriptionId}/updates?unit={unitId}{additionalParams}")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - + var subscriptionId = Guid.NewGuid().ToString(); + var unitId = Guid.NewGuid().ToString(); + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unit}", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = port, + }, + }, + UpstreamPathTemplate = "/api/units/{subscriptionId}/updates?unit={unit}", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/subscriptions/{subscriptionId}/updates", $"?unitId={unitId}{additionalParams}", "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/units/{subscriptionId}/updates?unit={unitId}{additionalParams}")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + [Theory(DisplayName = "1174: " + nameof(Should_return_200_and_forward_query_parameters_without_duplicates))] - [InlineData("projectNumber=45&startDate=2019-12-12&endDate=2019-12-12", "endDate=2019-12-12&projectNumber=45&startDate=2019-12-12")] - [InlineData("$filter=ProjectNumber eq 45 and DateOfSale ge 2020-03-01T00:00:00z and DateOfSale le 2020-03-15T00:00:00z", "$filter=ProjectNumber%20eq%2045%20and%20DateOfSale%20ge%202020-03-01T00:00:00z%20and%20DateOfSale%20le%202020-03-15T00:00:00z")] - public void Should_return_200_and_forward_query_parameters_without_duplicates(string everythingelse, string expectedOrdered) + [InlineData("projectNumber=45&startDate=2019-12-12&endDate=2019-12-12", "endDate=2019-12-12&projectNumber=45&startDate=2019-12-12")] + [InlineData("$filter=ProjectNumber eq 45 and DateOfSale ge 2020-03-01T00:00:00z and DateOfSale le 2020-03-15T00:00:00z", "$filter=ProjectNumber%20eq%2045%20and%20DateOfSale%20ge%202020-03-01T00:00:00z%20and%20DateOfSale%20le%202020-03-15T00:00:00z")] + public void Should_return_200_and_forward_query_parameters_without_duplicates(string everythingelse, string expectedOrdered) + { + var port = PortFinder.GetRandomPort(); + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/contracts?{everythingelse}", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() { Host = "localhost", Port = port }, + }, + UpstreamPathTemplate = "/contracts?{everythingelse}", + UpstreamHttpMethod = new() { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/contracts", $"?{expectedOrdered}", "Hello from @sunilk3")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/contracts?{everythingelse}")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from @sunilk3")) + .BDDfy(); + } + + [Fact] + public void Should_return_response_200_with_odata_query_string() + { + var subscriptionId = Guid.NewGuid().ToString(); + var unitId = Guid.NewGuid().ToString(); + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/{everything}", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = port, + }, + }, + UpstreamPathTemplate = "/{everything}", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/odata/customers", "?$filter=Name%20eq%20'Sam'", "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/odata/customers?$filter=Name eq 'Sam' ")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void Should_return_response_200_with_query_string_upstream_template() + { + var subscriptionId = Guid.NewGuid().ToString(); + var unitId = Guid.NewGuid().ToString(); + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = port, + }, + }, + UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", string.Empty, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates?unitId={unitId}")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } + + [Fact] + public void Should_return_response_404_with_query_string_upstream_template_no_query_string() + { + var subscriptionId = Guid.NewGuid().ToString(); + var unitId = Guid.NewGuid().ToString(); + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = port, + }, + }, + UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", string.Empty, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_return_response_404_with_query_string_upstream_template_different_query_string() { - var port = PortFinder.GetRandomPort(); - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/contracts?{everythingelse}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() { Host = "localhost", Port = port }, - }, - UpstreamPathTemplate = "/contracts?{everythingelse}", - UpstreamHttpMethod = new() { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/contracts", $"?{expectedOrdered}", "Hello from @sunilk3")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/contracts?{everythingelse}")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from @sunilk3")) - .BDDfy(); - } + var subscriptionId = Guid.NewGuid().ToString(); + var unitId = Guid.NewGuid().ToString(); + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = port, + }, + }, + UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", string.Empty, "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates?test=1")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) + .BDDfy(); + } + + [Fact] + public void Should_return_response_200_with_query_string_upstream_template_multiple_params() + { + var subscriptionId = Guid.NewGuid().ToString(); + var unitId = Guid.NewGuid().ToString(); + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new() + { + DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() + { + Host = "localhost", + Port = port, + }, + }, + UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", "?productId=1", "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates?unitId={unitId}&productId=1")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); + } - [Fact] - public void Should_return_response_200_with_odata_query_string() - { - var subscriptionId = Guid.NewGuid().ToString(); - var unitId = Guid.NewGuid().ToString(); - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/{everything}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/{everything}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", "/odata/customers", "?$filter=Name%20eq%20'Sam'", "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway("/odata/customers?$filter=Name eq 'Sam' ")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void Should_return_response_200_with_query_string_upstream_template() - { - var subscriptionId = Guid.NewGuid().ToString(); - var unitId = Guid.NewGuid().ToString(); - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", string.Empty, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates?unitId={unitId}")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - - [Fact] - public void Should_return_response_404_with_query_string_upstream_template_no_query_string() - { - var subscriptionId = Guid.NewGuid().ToString(); - var unitId = Guid.NewGuid().ToString(); - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", string.Empty, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) - .BDDfy(); - } - - [Fact] - public void Should_return_response_404_with_query_string_upstream_template_different_query_string() - { - var subscriptionId = Guid.NewGuid().ToString(); - var unitId = Guid.NewGuid().ToString(); - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", string.Empty, "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates?test=1")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.NotFound)) - .BDDfy(); - } - - [Fact] - public void Should_return_response_200_with_query_string_upstream_template_multiple_params() - { - var subscriptionId = Guid.NewGuid().ToString(); - var unitId = Guid.NewGuid().ToString(); - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new() - { - DownstreamPathTemplate = "/api/units/{subscriptionId}/{unitId}/updates", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() - { - Host = "localhost", - Port = port, - }, - }, - UpstreamPathTemplate = "/api/subscriptions/{subscriptionId}/updates?unitId={unitId}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/api/units/{subscriptionId}/{unitId}/updates", "?productId=1", "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/api/subscriptions/{subscriptionId}/updates?unitId={unitId}&productId=1")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - /// /// To reproduce 1288: query string should contain the placeholder name and value. /// - [Fact(DisplayName = "1288: " + nameof(Should_copy_query_string_to_downstream_path))] - public void Should_copy_query_string_to_downstream_path() + [Fact(DisplayName = "1288: " + nameof(Should_copy_query_string_to_downstream_path))] + public void Should_copy_query_string_to_downstream_path() { - var idName = "id"; + var idName = "id"; var idValue = "3"; var queryName = idName + "1"; - var queryValue = "2" + idValue + "12"; - var port = PortFinder.GetRandomPort(); - - var configuration = new FileConfiguration - { - Routes = new List - { - new FileRoute - { - DownstreamPathTemplate = $"/cpx/t1/{{{idName}}}", - DownstreamScheme = "http", - DownstreamHostAndPorts = new List - { - new() { Host = "localhost", Port = port }, - }, - UpstreamPathTemplate = $"/safe/{{{idName}}}", - UpstreamHttpMethod = new List { "Get" }, - }, - }, - }; - - this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/cpx/t1/{idValue}", $"?{queryName}={queryValue}", "Hello from Laura")) - .And(x => _steps.GivenThereIsAConfiguration(configuration)) - .And(x => _steps.GivenOcelotIsRunning()) - .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/safe/{idValue}?{queryName}={queryValue}")) - .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); + var queryValue = "2" + idValue + "12"; + var port = PortFinder.GetRandomPort(); + + var configuration = new FileConfiguration + { + Routes = new List + { + new FileRoute + { + DownstreamPathTemplate = $"/cpx/t1/{{{idName}}}", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new() { Host = "localhost", Port = port }, + }, + UpstreamPathTemplate = $"/safe/{{{idName}}}", + UpstreamHttpMethod = new List { "Get" }, + }, + }, + }; + + this.Given(x => x.GivenThereIsAServiceRunningOn($"http://localhost:{port}", $"/cpx/t1/{idValue}", $"?{queryName}={queryValue}", "Hello from Laura")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIGetUrlOnTheApiGateway($"/safe/{idValue}?{queryName}={queryValue}")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .BDDfy(); } - private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, string queryString, string responseBody) - { - _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => - { - if ((context.Request.PathBase.Value != basePath) || context.Request.QueryString.Value != queryString) - { - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsync("downstream path didnt match base path"); - } - else - { - context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync(responseBody); - } - }); - } - - public void Dispose() - { - _serviceHandler?.Dispose(); + private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath, string queryString, string responseBody) + { + _serviceHandler.GivenThereIsAServiceRunningOn(baseUrl, basePath, async context => + { + if (context.Request.PathBase.Value != basePath || context.Request.QueryString.Value != queryString) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsync("downstream path didnt match base path"); + } + else + { + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync(responseBody); + } + }); + } + + public void Dispose() + { + _serviceHandler?.Dispose(); _steps.Dispose(); - GC.SuppressFinalize(this); - } - } -} + GC.SuppressFinalize(this); + } + } +} diff --git a/test/Ocelot.Testing/PortFinder.cs b/test/Ocelot.Testing/PortFinder.cs index 4b661ada7a..6eb6b64d4a 100644 --- a/test/Ocelot.Testing/PortFinder.cs +++ b/test/Ocelot.Testing/PortFinder.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; diff --git a/test/Ocelot.UnitTests/Configuration/AggregatesCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/AggregatesCreatorTests.cs index 469e3d7f27..3d4c3ff635 100644 --- a/test/Ocelot.UnitTests/Configuration/AggregatesCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/AggregatesCreatorTests.cs @@ -9,17 +9,21 @@ namespace Ocelot.UnitTests.Configuration public class AggregatesCreatorTests : UnitTest { private readonly AggregatesCreator _creator; - private readonly Mock _utpCreator; + private readonly Mock _utpCreator; + private readonly Mock _uhtpCreator; private FileConfiguration _fileConfiguration; private List _routes; private List _result; private UpstreamPathTemplate _aggregate1Utp; - private UpstreamPathTemplate _aggregate2Utp; + private UpstreamPathTemplate _aggregate2Utp; + private Dictionary _headerTemplates1; + private Dictionary _headerTemplates2; public AggregatesCreatorTests() { - _utpCreator = new Mock(); - _creator = new AggregatesCreator(_utpCreator.Object); + _utpCreator = new Mock(); + _uhtpCreator = new Mock(); + _creator = new AggregatesCreator(_utpCreator.Object, _uhtpCreator.Object); } [Fact] @@ -82,7 +86,8 @@ public void should_create_aggregates() this.Given(_ => GivenThe(fileConfig)) .And(_ => GivenThe(routes)) - .And(_ => GivenTheUtpCreatorReturns()) + .And(_ => GivenTheUtpCreatorReturns()) + .And(_ => GivenTheUhtpCreatorReturns()) .When(_ => WhenICreate()) .Then(_ => ThenTheUtpCreatorIsCalledCorrectly()) .And(_ => ThenTheAggregatesAreCreated()) @@ -96,14 +101,16 @@ private void ThenTheAggregatesAreCreated() _result[0].UpstreamHttpMethod.ShouldContain(x => x == HttpMethod.Get); _result[0].UpstreamHost.ShouldBe(_fileConfiguration.Aggregates[0].UpstreamHost); - _result[0].UpstreamTemplatePattern.ShouldBe(_aggregate1Utp); + _result[0].UpstreamTemplatePattern.ShouldBe(_aggregate1Utp); + _result[0].UpstreamHeaderTemplates.ShouldBe(_headerTemplates1); _result[0].Aggregator.ShouldBe(_fileConfiguration.Aggregates[0].Aggregator); _result[0].DownstreamRoute.ShouldContain(x => x == _routes[0].DownstreamRoute[0]); _result[0].DownstreamRoute.ShouldContain(x => x == _routes[1].DownstreamRoute[0]); _result[1].UpstreamHttpMethod.ShouldContain(x => x == HttpMethod.Get); _result[1].UpstreamHost.ShouldBe(_fileConfiguration.Aggregates[1].UpstreamHost); - _result[1].UpstreamTemplatePattern.ShouldBe(_aggregate2Utp); + _result[1].UpstreamTemplatePattern.ShouldBe(_aggregate2Utp); + _result[1].UpstreamHeaderTemplates.ShouldBe(_headerTemplates2); _result[1].Aggregator.ShouldBe(_fileConfiguration.Aggregates[1].Aggregator); _result[1].DownstreamRoute.ShouldContain(x => x == _routes[2].DownstreamRoute[0]); _result[1].DownstreamRoute.ShouldContain(x => x == _routes[3].DownstreamRoute[0]); @@ -123,6 +130,16 @@ private void GivenTheUtpCreatorReturns() _utpCreator.SetupSequence(x => x.Create(It.IsAny())) .Returns(_aggregate1Utp) .Returns(_aggregate2Utp); + } + + private void GivenTheUhtpCreatorReturns() + { + _headerTemplates1 = new Dictionary(); + _headerTemplates2 = new Dictionary(); + + _uhtpCreator.SetupSequence(x => x.Create(It.IsAny())) + .Returns(_headerTemplates1) + .Returns(_headerTemplates2); } private void ThenTheResultIsEmpty() diff --git a/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs index 382eb3a44a..2be7ccd2a6 100644 --- a/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs @@ -13,6 +13,7 @@ public class RoutesCreatorTests : UnitTest private readonly Mock _cthCreator; private readonly Mock _aoCreator; private readonly Mock _utpCreator; + private readonly Mock _uhtpCreator; private readonly Mock _ridkCreator; private readonly Mock _qosoCreator; private readonly Mock _rroCreator; @@ -40,7 +41,8 @@ public class RoutesCreatorTests : UnitTest private List _dhp; private LoadBalancerOptions _lbo; private List _result; - private Version _expectedVersion; + private Version _expectedVersion; + private Dictionary _uht; public RoutesCreatorTests() { @@ -59,6 +61,7 @@ public RoutesCreatorTests() _rrkCreator = new Mock(); _soCreator = new Mock(); _versionCreator = new Mock(); + _uhtpCreator = new Mock(); _creator = new RoutesCreator( _cthCreator.Object, @@ -75,7 +78,8 @@ public RoutesCreatorTests() _lboCreator.Object, _rrkCreator.Object, _soCreator.Object, - _versionCreator.Object + _versionCreator.Object, + _uhtpCreator.Object ); } @@ -165,7 +169,8 @@ private void GivenTheDependenciesAreSetUpCorrectly() _hho = new HttpHandlerOptionsBuilder().Build(); _ht = new HeaderTransformations(new List(), new List(), new List(), new List()); _dhp = new List(); - _lbo = new LoadBalancerOptionsBuilder().Build(); + _lbo = new LoadBalancerOptionsBuilder().Build(); + _uht = new Dictionary(); _rroCreator.Setup(x => x.Create(It.IsAny())).Returns(_rro); _ridkCreator.Setup(x => x.Create(It.IsAny(), It.IsAny())).Returns(_requestId); @@ -180,7 +185,8 @@ private void GivenTheDependenciesAreSetUpCorrectly() _hfarCreator.Setup(x => x.Create(It.IsAny())).Returns(_ht); _daCreator.Setup(x => x.Create(It.IsAny())).Returns(_dhp); _lboCreator.Setup(x => x.Create(It.IsAny())).Returns(_lbo); - _versionCreator.Setup(x => x.Create(It.IsAny())).Returns(_expectedVersion); + _versionCreator.Setup(x => x.Create(It.IsAny())).Returns(_expectedVersion); + _uhtpCreator.Setup(x => x.Create(It.IsAny())).Returns(_uht); } private void ThenTheRoutesAreCreated() @@ -249,7 +255,8 @@ private void ThenTheRouteIsSet(FileRoute expected, int routeIndex) .ShouldContain(x => x == expected.UpstreamHttpMethod[1]); _result[routeIndex].UpstreamHost.ShouldBe(expected.UpstreamHost); _result[routeIndex].DownstreamRoute.Count.ShouldBe(1); - _result[routeIndex].UpstreamTemplatePattern.ShouldBe(_upt); + _result[routeIndex].UpstreamTemplatePattern.ShouldBe(_upt); + _result[routeIndex].UpstreamHeaderTemplates.ShouldBe(_uht); } private void ThenTheDepsAreCalledFor(FileRoute fileRoute, FileGlobalConfiguration globalConfig) diff --git a/test/Ocelot.UnitTests/Configuration/UpstreamHeaderTemplatePatternCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/UpstreamHeaderTemplatePatternCreatorTests.cs new file mode 100644 index 0000000000..49cf841ac7 --- /dev/null +++ b/test/Ocelot.UnitTests/Configuration/UpstreamHeaderTemplatePatternCreatorTests.cs @@ -0,0 +1,45 @@ +using Ocelot.Configuration.Creator; +using Ocelot.Configuration.File; +using Ocelot.Values; + +namespace Ocelot.UnitTests.Configuration; + +public class UpstreamHeaderTemplatePatternCreatorTests +{ + private readonly UpstreamHeaderTemplatePatternCreator _creator; + + public UpstreamHeaderTemplatePatternCreatorTests() + { + _creator = new(); + } + + [Trait("PR", "1312")] + [Trait("Feat", "360")] + [Theory(DisplayName = "Should create pattern")] + [InlineData("country", "a text without placeholders", "^(?i)a text without placeholders$", " without placeholders")] + [InlineData("country", "a text without placeholders", "^a text without placeholders$", " Route is case sensitive", true)] + [InlineData("country", "{header:start}rest of the text", "^(?i)(?.+)rest of the text$", " with placeholder in the beginning")] + [InlineData("country", "rest of the text{header:end}", "^(?i)rest of the text(?.+)$", " with placeholder at the end")] + [InlineData("country", "{header:countrycode}", "^(?i)(?.+)$", " with placeholder only")] + [InlineData("country", "any text {header:cc} and other {header:version} and {header:bob} the end", "^(?i)any text (?.+) and other (?.+) and (?.+) the end$", " with more placeholders")] + public void Create_WithUpstreamHeaderTemplates_ShouldCreatePattern(string key, string template, string expected, string withMessage, bool? isCaseSensitive = null) + { + // Arrange + var fileRoute = new FileRoute + { + RouteIsCaseSensitive = isCaseSensitive ?? false, + UpstreamHeaderTemplates = new Dictionary + { + [key] = template, + }, + }; + + // Act + var actual = _creator.Create(fileRoute); + + // Assert + var message = nameof(Create_WithUpstreamHeaderTemplates_ShouldCreatePattern).Replace('_', ' ') + withMessage; + actual[key].ShouldNotBeNull() + .Template.ShouldBe(expected, message); + } +} diff --git a/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs b/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs index 3a87dec2f6..f3998c8780 100644 --- a/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs @@ -536,7 +536,7 @@ public void Configuration_is_valid_with_duplicate_routes_but_one_upstreamhost_is .When(x => x.WhenIValidateTheConfiguration()) .Then(x => x.ThenTheResultIsValid()) .BDDfy(); - } + } [Fact] public void Configuration_is_invalid_with_invalid_rate_limit_configuration() @@ -718,6 +718,102 @@ public void Configuration_is_not_valid_when_host_and_port_is_empty() .Then(x => x.ThenTheResultIsNotValid()) .And(x => x.ThenTheErrorMessageAtPositionIs(0, "When not using service discovery Host must be set on DownstreamHostAndPorts if you are not using Route.Host or Ocelot cannot find your service!")) .BDDfy(); + } + + [Fact] + [Trait("PR", "1312")] + [Trait("Feat", "360")] + public void Configuration_is_not_valid_when_upstream_headers_the_same() + { + // Arrange + var route1 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/api/products/", new() + { + { "header1", "value1" }, + { "header2", "value2" }, + }); + var route2 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/www/test/", new() + { + { "header2", "value2" }, + { "header1", "value1" }, + }); + GivenAConfiguration(route1, route2); + + // Act + WhenIValidateTheConfiguration(); + + // Assert + ThenTheResultIsNotValid(); + ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate"); + } + + [Fact] + [Trait("PR", "1312")] + [Trait("Feat", "360")] + public void Configuration_is_valid_when_upstream_headers_not_the_same() + { + // Arrange + var route1 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/api/products/", new() + { + { "header1", "value1" }, + { "header2", "value2" }, + }); + var route2 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/www/test/", new() + { + { "header2", "value2" }, + { "header1", "valueDIFFERENT" }, + }); + GivenAConfiguration(route1, route2); + + // Act + WhenIValidateTheConfiguration(); + + // Assert + ThenTheResultIsValid(); + } + + [Fact] + [Trait("PR", "1312")] + [Trait("Feat", "360")] + public void Configuration_is_valid_when_upstream_headers_count_not_the_same() + { + // Arrange + var route1 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/api/products/", new() + { + { "header1", "value1" }, + { "header2", "value2" }, + }); + var route2 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/www/test/", new() + { + { "header2", "value2" }, + }); + GivenAConfiguration(route1, route2); + + // Act + WhenIValidateTheConfiguration(); + + // Assert + ThenTheResultIsValid(); + } + + [Fact] + [Trait("PR", "1312")] + [Trait("Feat", "360")] + public void Configuration_is_valid_when_one_upstream_headers_empty_and_other_not_empty() + { + // Arrange + var route1 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/api/products/", new() + { + { "header1", "value1" }, + { "header2", "value2" }, + }); + var route2 = GivenRouteWithUpstreamHeaderTemplates("/asdf/", "/www/test/", new()); + GivenAConfiguration(route1, route2); + + // Act + WhenIValidateTheConfiguration(); + + // Assert + ThenTheResultIsValid(); } [Theory] @@ -804,6 +900,18 @@ public void Configuration_is_invalid_when_placeholder_is_used_twice_in_downstrea DownstreamPathTemplate = "/", DownstreamScheme = Uri.UriSchemeHttp, ServiceName = "test", + }; + + private static FileRoute GivenRouteWithUpstreamHeaderTemplates(string upstream, string downstream, Dictionary templates) => new() + { + UpstreamPathTemplate = upstream, + DownstreamPathTemplate = downstream, + DownstreamHostAndPorts = new() + { + new("bbc.co.uk", 123), + }, + UpstreamHttpMethod = new() { HttpMethods.Get }, + UpstreamHeaderTemplates = templates, }; private void GivenAConfiguration(FileConfiguration fileConfiguration) => _fileConfiguration = fileConfiguration; @@ -902,12 +1010,10 @@ private class TestHandler : AuthenticationHandler // It can be set directly or by registering a provider in the dependency injection container. #if NET8_0_OR_GREATER public TestHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) - { - } + { } #else public TestHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) - { - } + { } #endif protected override Task HandleAuthenticateAsync() diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs index 86ba4078a3..f4cb323f40 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteCreatorTests.cs @@ -16,7 +16,8 @@ public class DownstreamRouteCreatorTests : UnitTest private Response _result; private string _upstreamHost; private string _upstreamUrlPath; - private string _upstreamHttpMethod; + private string _upstreamHttpMethod; + private Dictionary _upstreamHeaders; private IInternalConfiguration _configuration; private readonly Mock _qosOptionsCreator; private Response _resultTwo; @@ -259,7 +260,8 @@ private void GivenTheConfiguration(IInternalConfiguration config) { _upstreamHost = "doesnt matter"; _upstreamUrlPath = "/auth/test"; - _upstreamHttpMethod = "GET"; + _upstreamHttpMethod = "GET"; + _upstreamHeaders = new Dictionary(); _configuration = config; } @@ -278,12 +280,12 @@ private void ThenTheHandlerOptionsAreSet() private void WhenICreate() { - _result = _creator.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost); + _result = _creator.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost, _upstreamHeaders); } private void WhenICreateAgain() { - _resultTwo = _creator.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost); + _resultTwo = _creator.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost, _upstreamHeaders); } private void ThenTheDownstreamRoutesAreTheSameReference() diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs index 6d331a36ee..eb6ebe68f2 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs @@ -74,7 +74,7 @@ private void GivenTheDownStreamRouteFinderReturns(DownstreamRouteHolder downstre { _downstreamRoute = new OkResponse(downstreamRoute); _finder - .Setup(x => x.Get(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.Get(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) .Returns(_downstreamRoute); } diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs index e9b2bf8ed0..ec6026a5be 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs @@ -2,31 +2,37 @@ using Ocelot.Configuration.Builder; using Ocelot.DownstreamRouteFinder; using Ocelot.DownstreamRouteFinder.Finder; +using Ocelot.DownstreamRouteFinder.HeaderMatcher; using Ocelot.DownstreamRouteFinder.UrlMatcher; using Ocelot.Responses; using Ocelot.Values; namespace Ocelot.UnitTests.DownstreamRouteFinder -{ +{ public class DownstreamRouteFinderTests : UnitTest { private readonly IDownstreamRouteProvider _downstreamRouteFinder; - private readonly Mock _mockMatcher; - private readonly Mock _finder; + private readonly Mock _mockUrlMatcher; + private readonly Mock _mockHeadersMatcher; + private readonly Mock _urlPlaceholderFinder; + private readonly Mock _headerPlaceholderFinder; private string _upstreamUrlPath; private Response _result; private List _routesConfig; private InternalConfiguration _config; private Response _match; private string _upstreamHttpMethod; - private string _upstreamHost; + private string _upstreamHost; + private Dictionary _upstreamHeaders; private string _upstreamQuery; public DownstreamRouteFinderTests() { - _mockMatcher = new Mock(); - _finder = new Mock(); - _downstreamRouteFinder = new Ocelot.DownstreamRouteFinder.Finder.DownstreamRouteFinder(_mockMatcher.Object, _finder.Object); + _mockUrlMatcher = new Mock(); + _mockHeadersMatcher = new Mock(); + _urlPlaceholderFinder = new Mock(); + _headerPlaceholderFinder = new Mock(); + _downstreamRouteFinder = new Ocelot.DownstreamRouteFinder.Finder.DownstreamRouteFinder(_mockUrlMatcher.Object, _urlPlaceholderFinder.Object, _mockHeadersMatcher.Object, _headerPlaceholderFinder.Object); } [Fact] @@ -37,6 +43,7 @@ public void should_return_highest_priority_when_first() this.Given(x => x.GivenThereIsAnUpstreamUrlPath("someUpstreamPath")) .And(x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -58,7 +65,8 @@ public void should_return_highest_priority_when_first() .WithUpstreamPathTemplate(new UpstreamPathTemplate("test", 0, false, "someUpstreamPath")) .Build(), }, string.Empty, serviceProviderConfig)) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Post")) .When(x => x.WhenICallTheFinder()) .Then(x => x.ThenTheFollowingIsReturned(new DownstreamRouteHolder(new List(), @@ -83,6 +91,7 @@ public void should_return_highest_priority_when_lowest() this.Given(x => x.GivenThereIsAnUpstreamUrlPath("someUpstreamPath")) .And(x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -104,7 +113,8 @@ public void should_return_highest_priority_when_lowest() .WithUpstreamPathTemplate(new UpstreamPathTemplate("test", 1, false, "someUpstreamPath")) .Build(), }, string.Empty, serviceProviderConfig)) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Post")) .When(x => x.WhenICallTheFinder()) .Then(x => x.ThenTheFollowingIsReturned(new DownstreamRouteHolder(new List(), @@ -130,6 +140,7 @@ public void should_return_route() .And(x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>( new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -143,7 +154,8 @@ public void should_return_route() .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then( @@ -172,6 +184,8 @@ public void should_not_append_slash_to_upstream_url_path() .And(x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>( new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -185,7 +199,8 @@ public void should_not_append_slash_to_upstream_url_path() .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then( @@ -215,6 +230,7 @@ public void should_return_route_if_upstream_path_and_upstream_template_are_the_s x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -228,7 +244,8 @@ public void should_return_route_if_upstream_path_and_upstream_template_are_the_s .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then( @@ -256,6 +273,7 @@ public void should_return_correct_route_for_http_verb() x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -278,7 +296,8 @@ public void should_return_correct_route_for_http_verb() .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Post")) .When(x => x.WhenICallTheFinder()) .Then( @@ -315,7 +334,8 @@ public void should_not_return_route() .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(false)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(false)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then( @@ -334,6 +354,7 @@ public void should_return_correct_route_for_http_verb_setting_multiple_upstream_ x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -347,7 +368,8 @@ public void should_return_correct_route_for_http_verb_setting_multiple_upstream_ .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Post")) .When(x => x.WhenICallTheFinder()) .Then( @@ -375,6 +397,7 @@ public void should_return_correct_route_for_http_verb_setting_all_upstream_http_ x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -388,7 +411,8 @@ public void should_return_correct_route_for_http_verb_setting_all_upstream_http_ .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Post")) .When(x => x.WhenICallTheFinder()) .Then( @@ -416,6 +440,7 @@ public void should_not_return_route_for_http_verb_not_setting_in_upstream_http_m x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -429,7 +454,8 @@ public void should_not_return_route_for_http_verb_not_setting_in_upstream_http_m .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Post")) .When(x => x.WhenICallTheFinder()) .Then(x => x.ThenAnErrorResponseIsReturned()) @@ -447,6 +473,7 @@ public void should_return_route_when_host_matches() .And(x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>( new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -461,7 +488,8 @@ public void should_return_route_when_host_matches() .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then( @@ -491,6 +519,7 @@ public void should_return_route_when_upstreamhost_is_null() .And(x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>( new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -504,7 +533,8 @@ public void should_return_route_when_upstreamhost_is_null() .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then( @@ -532,6 +562,7 @@ public void should_not_return_route_when_host_doesnt_match() this.Given(x => x.GivenThereIsAnUpstreamUrlPath("matchInUrlMatcher/")) .And(x => GivenTheUpstreamHostIs("DONTMATCH")) .And(x => x.GivenTheTemplateVariableAndNameFinderReturns(new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -556,7 +587,8 @@ public void should_not_return_route_when_host_doesnt_match() .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then(x => x.ThenAnErrorResponseIsReturned()) @@ -572,6 +604,7 @@ public void should_not_return_route_when_host_doesnt_match_with_empty_upstream_h this.Given(x => x.GivenThereIsAnUpstreamUrlPath("matchInUrlMatcher/")) .And(x => GivenTheUpstreamHostIs("DONTMATCH")) .And(x => x.GivenTheTemplateVariableAndNameFinderReturns(new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -586,7 +619,8 @@ public void should_not_return_route_when_host_doesnt_match_with_empty_upstream_h .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then(x => x.ThenAnErrorResponseIsReturned()) @@ -602,6 +636,7 @@ public void should_return_route_when_host_does_match_with_empty_upstream_http_me this.Given(x => x.GivenThereIsAnUpstreamUrlPath("matchInUrlMatcher/")) .And(x => GivenTheUpstreamHostIs("MATCH")) .And(x => x.GivenTheTemplateVariableAndNameFinderReturns(new OkResponse>(new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -616,7 +651,8 @@ public void should_return_route_when_host_does_match_with_empty_upstream_http_me .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .And(x => x.ThenTheUrlMatcherIsCalledCorrectly(1, 0)) @@ -633,6 +669,7 @@ public void should_return_route_when_host_matches_but_null_host_on_same_path_fir .And(x => x.GivenTheTemplateVariableAndNameFinderReturns( new OkResponse>( new List()))) + .And(x => x.GivenTheHeaderPlaceholderAndNameFinderReturns(new List())) .And(x => x.GivenTheConfigurationIs(new List { new RouteBuilder() @@ -656,7 +693,8 @@ public void should_return_route_when_host_matches_but_null_host_on_same_path_fir .Build(), }, string.Empty, serviceProviderConfig )) - .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true)))) + .And(x => x.GivenTheHeadersMatcherReturns(true)) .And(x => x.GivenTheUpstreamHttpMethodIs("Get")) .When(x => x.WhenICallTheFinder()) .Then( @@ -675,8 +713,124 @@ public void should_return_route_when_host_matches_but_null_host_on_same_path_fir .And(x => x.ThenTheUrlMatcherIsCalledCorrectly(1, 0)) .And(x => x.ThenTheUrlMatcherIsCalledCorrectly(1, 1)) .BDDfy(); - } - + } + + [Fact] + [Trait("PR", "1312")] + [Trait("Feat", "360")] + public void Should_return_route_when_upstream_headers_match() + { + // Arrange + var serviceProviderConfig = new ServiceProviderConfigurationBuilder().Build(); + var upstreamHeaders = new Dictionary() + { + ["header1"] = "headerValue1", + ["header2"] = "headerValue2", + ["header3"] = "headerValue3", + }; + var upstreamHeadersConfig = new Dictionary() + { + ["header1"] = new UpstreamHeaderTemplate("headerValue1", "headerValue1"), + ["header2"] = new UpstreamHeaderTemplate("headerValue2", "headerValue2"), + }; + var urlPlaceholders = new List { new PlaceholderNameAndValue("url", "urlValue") }; + var headerPlaceholders = new List { new PlaceholderNameAndValue("header", "headerValue") }; + + GivenThereIsAnUpstreamUrlPath("matchInUrlMatcher/"); + GivenTheUpstreamHeadersIs(upstreamHeaders); + GivenTheTemplateVariableAndNameFinderReturns(new OkResponse>(urlPlaceholders)); + GivenTheHeaderPlaceholderAndNameFinderReturns(headerPlaceholders); + GivenTheConfigurationIs( + new() + { + new RouteBuilder() + .WithDownstreamRoute(new DownstreamRouteBuilder() + .WithDownstreamPathTemplate("someDownstreamPath") + .WithUpstreamHttpMethod(new() {"Get"}) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .Build()) + .WithUpstreamHttpMethod(new() {"Get"}) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .WithUpstreamHeaders(upstreamHeadersConfig) + .Build(), + }, + string.Empty, + serviceProviderConfig); + GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true))); + GivenTheHeadersMatcherReturns(true); + GivenTheUpstreamHttpMethodIs("Get"); + + // Act + WhenICallTheFinder(); + + // Assert + ThenTheFollowingIsReturned(new DownstreamRouteHolder( + urlPlaceholders.Union(headerPlaceholders).ToList(), + new RouteBuilder() + .WithDownstreamRoute(new DownstreamRouteBuilder() + .WithDownstreamPathTemplate("someDownstreamPath") + .WithUpstreamHttpMethod(new() { "Get" }) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .Build()) + .WithUpstreamHttpMethod(new() { "Get" }) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .Build() + )); + ThenTheUrlMatcherIsCalledCorrectly(); + } + + [Fact] + [Trait("PR", "1312")] + [Trait("Feat", "360")] + public void Should_not_return_route_when_upstream_headers_dont_match() + { + // Arrange + var serviceProviderConfig = new ServiceProviderConfigurationBuilder().Build(); + var upstreamHeadersConfig = new Dictionary() + { + ["header1"] = new UpstreamHeaderTemplate("headerValue1", "headerValue1"), + ["header2"] = new UpstreamHeaderTemplate("headerValue2", "headerValue2"), + }; + + GivenThereIsAnUpstreamUrlPath("matchInUrlMatcher/"); + GivenTheUpstreamHeadersIs(new Dictionary() { { "header1", "headerValue1" } }); + GivenTheTemplateVariableAndNameFinderReturns(new OkResponse>(new List())); + GivenTheHeaderPlaceholderAndNameFinderReturns(new List()); + GivenTheConfigurationIs(new List + { + new RouteBuilder() + .WithDownstreamRoute(new DownstreamRouteBuilder() + .WithDownstreamPathTemplate("someDownstreamPath") + .WithUpstreamHttpMethod(new() { "Get" }) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .Build()) + .WithUpstreamHttpMethod(new() { "Get" }) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .WithUpstreamHeaders(upstreamHeadersConfig) + .Build(), + new RouteBuilder() + .WithDownstreamRoute(new DownstreamRouteBuilder() + .WithDownstreamPathTemplate("someDownstreamPath") + .WithUpstreamHttpMethod(new() { "Get" }) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .Build()) + .WithUpstreamHttpMethod(new() { "Get" }) + .WithUpstreamPathTemplate(new UpstreamPathTemplate("someUpstreamPath", 1, false, "someUpstreamPath")) + .WithUpstreamHeaders(upstreamHeadersConfig) + .Build(), + }, string.Empty, serviceProviderConfig + ); + GivenTheUrlMatcherReturns(new OkResponse(new UrlMatch(true))); + GivenTheHeadersMatcherReturns(false); + GivenTheUpstreamHttpMethodIs("Get"); + + // Act + WhenICallTheFinder(); + + // Assert + ThenAnErrorResponseIsReturned(); + } + private void GivenTheUpstreamHostIs(string upstreamHost) { _upstreamHost = upstreamHost; @@ -684,14 +838,26 @@ private void GivenTheUpstreamHostIs(string upstreamHost) private void GivenTheTemplateVariableAndNameFinderReturns(Response> response) { - _finder + _urlPlaceholderFinder .Setup(x => x.Find(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(response); } + private void GivenTheHeaderPlaceholderAndNameFinderReturns(List placeholders) + { + _headerPlaceholderFinder + .Setup(x => x.Find(It.IsAny>(), It.IsAny>())) + .Returns(placeholders); + } + private void GivenTheUpstreamHttpMethodIs(string upstreamHttpMethod) { _upstreamHttpMethod = upstreamHttpMethod; + } + + private void GivenTheUpstreamHeadersIs(Dictionary upstreamHeaders) + { + _upstreamHeaders = upstreamHeaders; } private void ThenAnErrorResponseIsReturned() @@ -701,34 +867,41 @@ private void ThenAnErrorResponseIsReturned() private void ThenTheUrlMatcherIsCalledCorrectly() { - _mockMatcher + _mockUrlMatcher .Verify(x => x.Match(_upstreamUrlPath, _upstreamQuery, _routesConfig[0].UpstreamTemplatePattern), Times.Once); } private void ThenTheUrlMatcherIsCalledCorrectly(int times, int index = 0) { - _mockMatcher + _mockUrlMatcher .Verify(x => x.Match(_upstreamUrlPath, _upstreamQuery, _routesConfig[index].UpstreamTemplatePattern), Times.Exactly(times)); } private void ThenTheUrlMatcherIsCalledCorrectly(string expectedUpstreamUrlPath) { - _mockMatcher + _mockUrlMatcher .Verify(x => x.Match(expectedUpstreamUrlPath, _upstreamQuery, _routesConfig[0].UpstreamTemplatePattern), Times.Once); } private void ThenTheUrlMatcherIsNotCalled() { - _mockMatcher + _mockUrlMatcher .Verify(x => x.Match(_upstreamUrlPath, _upstreamQuery, _routesConfig[0].UpstreamTemplatePattern), Times.Never); } private void GivenTheUrlMatcherReturns(Response match) { _match = match; - _mockMatcher + _mockUrlMatcher .Setup(x => x.Match(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(_match); + } + + private void GivenTheHeadersMatcherReturns(bool headersMatch) + { + _mockHeadersMatcher + .Setup(x => x.Match(It.IsAny>(), It.IsAny>())) + .Returns(headersMatch); } private void GivenTheConfigurationIs(List routesConfig, string adminPath, ServiceProviderConfiguration serviceProviderConfig) @@ -745,7 +918,7 @@ private void GivenThereIsAnUpstreamUrlPath(string upstreamUrlPath) private void WhenICallTheFinder() { - _result = _downstreamRouteFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost); + _result = _downstreamRouteFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); } private void ThenTheFollowingIsReturned(DownstreamRouteHolder expected) diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteProviderFactoryTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteProviderFactoryTests.cs index db89a5eebe..20426362a8 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteProviderFactoryTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteProviderFactoryTests.cs @@ -2,6 +2,7 @@ using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Configuration.Creator; +using Ocelot.DownstreamRouteFinder.HeaderMatcher; using Ocelot.DownstreamRouteFinder.UrlMatcher; using Ocelot.Logging; @@ -21,7 +22,9 @@ public DownstreamRouteProviderFactoryTests() { var services = new ServiceCollection(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/HeaderMatcher/HeaderPlaceholderNameAndValueFinderTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/HeaderMatcher/HeaderPlaceholderNameAndValueFinderTests.cs new file mode 100644 index 0000000000..b1a7676022 --- /dev/null +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/HeaderMatcher/HeaderPlaceholderNameAndValueFinderTests.cs @@ -0,0 +1,220 @@ +using Ocelot.DownstreamRouteFinder.HeaderMatcher; +using Ocelot.DownstreamRouteFinder.UrlMatcher; +using Ocelot.Values; + +namespace Ocelot.UnitTests.DownstreamRouteFinder.HeaderMatcher; + +[Trait("PR", "1312")] +[Trait("Feat", "360")] +public class HeaderPlaceholderNameAndValueFinderTests : UnitTest +{ + private readonly IHeaderPlaceholderNameAndValueFinder _finder; + private Dictionary _upstreamHeaders; + private Dictionary _upstreamHeaderTemplates; + private List _result; + + public HeaderPlaceholderNameAndValueFinderTests() + { + _finder = new HeaderPlaceholderNameAndValueFinder(); + } + + [Fact] + public void Should_return_no_placeholders() + { + // Arrange + var upstreamHeaderTemplates = new Dictionary(); + var upstreamHeaders = new Dictionary(); + var expected = new List(); + GivenUpstreamHeaderTemplatesAre(upstreamHeaderTemplates); + GivenUpstreamHeadersAre(upstreamHeaders); + + // Act + WhenICallFindPlaceholders(); + + // Assert + TheResultIs(expected); + } + + [Fact] + public void Should_return_one_placeholder_with_value_when_no_other_text() + { + // Arrange + var upstreamHeaderTemplates = new Dictionary + { + ["country"] = new("^(?i)(?.+)$", "{header:countrycode}"), + }; + var upstreamHeaders = new Dictionary + { + ["country"] = "PL", + }; + var expected = new List + { + new("{countrycode}", "PL"), + }; + GivenUpstreamHeaderTemplatesAre(upstreamHeaderTemplates); + GivenUpstreamHeadersAre(upstreamHeaders); + + // Act + WhenICallFindPlaceholders(); + + // Assert + TheResultIs(expected); + } + + [Fact] + public void Should_return_one_placeholder_with_value_when_other_text_on_the_right() + { + // Arrange + var upstreamHeaderTemplates = new Dictionary + { + ["country"] = new("^(?.+)-V1$", "{header:countrycode}-V1"), + }; + var upstreamHeaders = new Dictionary + { + ["country"] = "PL-V1", + }; + var expected = new List + { + new("{countrycode}", "PL"), + }; + GivenUpstreamHeaderTemplatesAre(upstreamHeaderTemplates); + GivenUpstreamHeadersAre(upstreamHeaders); + + // Act + WhenICallFindPlaceholders(); + + // Assert + TheResultIs(expected); + } + + [Fact] + public void Should_return_one_placeholder_with_value_when_other_text_on_the_left() + { + // Arrange + var upstreamHeaderTemplates = new Dictionary + { + ["country"] = new("^V1-(?.+)$", "V1-{header:countrycode}"), + }; + var upstreamHeaders = new Dictionary + { + ["country"] = "V1-PL", + }; + var expected = new List + { + new("{countrycode}", "PL"), + }; + GivenUpstreamHeaderTemplatesAre(upstreamHeaderTemplates); + GivenUpstreamHeadersAre(upstreamHeaders); + + // Act + WhenICallFindPlaceholders(); + + // Assert + TheResultIs(expected); + } + + [Fact] + public void Should_return_one_placeholder_with_value_when_other_texts_surrounding() + { + // Arrange + var upstreamHeaderTemplates = new Dictionary + { + ["country"] = new("^cc:(?.+)-V1$", "cc:{header:countrycode}-V1"), + }; + var upstreamHeaders = new Dictionary + { + ["country"] = "cc:PL-V1", + }; + var expected = new List + { + new("{countrycode}", "PL"), + }; + GivenUpstreamHeaderTemplatesAre(upstreamHeaderTemplates); + GivenUpstreamHeadersAre(upstreamHeaders); + + // Act + WhenICallFindPlaceholders(); + + // Assert + TheResultIs(expected); + } + + [Fact] + public void Should_return_two_placeholders_with_text_between() + { + // Arrange + var upstreamHeaderTemplates = new Dictionary + { + ["countryAndVersion"] = new("^(?i)(?.+)-(?.+)$", "{header:countrycode}-{header:version}"), + }; + var upstreamHeaders = new Dictionary + { + ["countryAndVersion"] = "PL-v1", + }; + var expected = new List + { + new("{countrycode}", "PL"), + new("{version}", "v1"), + }; + GivenUpstreamHeaderTemplatesAre(upstreamHeaderTemplates); + GivenUpstreamHeadersAre(upstreamHeaders); + + // Act + WhenICallFindPlaceholders(); + + // Assert + TheResultIs(expected); + } + + [Fact] + public void Should_return_placeholders_from_different_headers() + { + // Arrange + var upstreamHeaderTemplates = new Dictionary + { + ["country"] = new("^(?i)(?.+)$", "{header:countrycode}"), + ["version"] = new("^(?i)(?.+)$", "{header:version}"), + }; + var upstreamHeaders = new Dictionary + { + ["country"] = "PL", + ["version"] = "v1", + }; + var expected = new List + { + new("{countrycode}", "PL"), + new("{version}", "v1"), + }; + GivenUpstreamHeaderTemplatesAre(upstreamHeaderTemplates); + GivenUpstreamHeadersAre(upstreamHeaders); + + // Act + WhenICallFindPlaceholders(); + + // Assert + TheResultIs(expected); + } + + private void GivenUpstreamHeaderTemplatesAre(Dictionary upstreaHeaderTemplates) + { + _upstreamHeaderTemplates = upstreaHeaderTemplates; + } + + private void GivenUpstreamHeadersAre(Dictionary upstreamHeaders) + { + _upstreamHeaders = upstreamHeaders; + } + + private void WhenICallFindPlaceholders() + { + var result = _finder.Find(_upstreamHeaders, _upstreamHeaderTemplates); + _result = new(result); + } + + private void TheResultIs(List expected) + { + _result.ShouldNotBeNull(); + _result.Count.ShouldBe(expected.Count); + _result.ForEach(x => expected.Any(e => e.Name == x.Name && e.Value == x.Value).ShouldBeTrue()); + } +} diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/HeaderMatcher/HeadersToHeaderTemplatesMatcherTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/HeaderMatcher/HeadersToHeaderTemplatesMatcherTests.cs new file mode 100644 index 0000000000..fae3cc0c53 --- /dev/null +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/HeaderMatcher/HeadersToHeaderTemplatesMatcherTests.cs @@ -0,0 +1,293 @@ +using Ocelot.DownstreamRouteFinder.HeaderMatcher; +using Ocelot.Values; + +namespace Ocelot.UnitTests.DownstreamRouteFinder.HeaderMatcher; + +[Trait("PR", "1312")] +[Trait("Feat", "360")] +public class HeadersToHeaderTemplatesMatcherTests : UnitTest +{ + private readonly IHeadersToHeaderTemplatesMatcher _headerMatcher; + private Dictionary _upstreamHeaders; + private Dictionary _templateHeaders; + private bool _result; + + public HeadersToHeaderTemplatesMatcherTests() + { + _headerMatcher = new HeadersToHeaderTemplatesMatcher(); + } + + [Fact] + public void Should_match_when_no_template_headers() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "anyHeaderValue", + }; + var templateHeaders = new Dictionary(); + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsTrue(); + } + + [Fact] + public void Should_match_the_same_headers() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "anyHeaderValue", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^(?i)anyHeaderValue$", "anyHeaderValue"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsTrue(); + } + + [Fact] + public void Should_not_match_the_same_headers_when_differ_case_and_case_sensitive() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "ANYHEADERVALUE", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^anyHeaderValue$", "anyHeaderValue"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsFalse(); + } + + [Fact] + public void Should_match_the_same_headers_when_differ_case_and_case_insensitive() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "ANYHEADERVALUE", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^(?i)anyHeaderValue$", "anyHeaderValue"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsTrue(); + } + + [Fact] + public void Should_not_match_different_headers_values() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "anyHeaderValueDifferent", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^(?i)anyHeaderValue$", "anyHeaderValue"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsFalse(); + } + + [Fact] + public void Should_not_match_the_same_headers_names() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeaderDifferent"] = "anyHeaderValue", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^(?i)anyHeaderValue$", "anyHeaderValue"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsFalse(); + } + + [Fact] + public void Should_match_all_the_same_headers() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "anyHeaderValue", + ["notNeededHeader"] = "notNeededHeaderValue", + ["secondHeader"] = "secondHeaderValue", + ["thirdHeader"] = "thirdHeaderValue", + }; + var templateHeaders = new Dictionary() + { + ["secondHeader"] = new("^(?i)secondHeaderValue$", "secondHeaderValue"), + ["thirdHeader"] = new("^(?i)thirdHeaderValue$", "thirdHeaderValue"), + ["anyHeader"] = new("^(?i)anyHeaderValue$", "anyHeaderValue"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsTrue(); + } + + [Fact] + public void Should_not_match_the_headers_when_one_of_them_different() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "anyHeaderValue", + ["notNeededHeader"] = "notNeededHeaderValue", + ["secondHeader"] = "secondHeaderValueDIFFERENT", + ["thirdHeader"] = "thirdHeaderValue", + }; + var templateHeaders = new Dictionary() + { + ["secondHeader"] = new("^(?i)secondHeaderValue$", "secondHeaderValue"), + ["thirdHeader"] = new("^(?i)thirdHeaderValue$", "thirdHeaderValue"), + ["anyHeader"] = new("^(?i)anyHeaderValue$", "anyHeaderValue"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsFalse(); + } + + [Fact] + public void Should_match_the_header_with_placeholder() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "PL", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^(?i)(?.+)$", "{header:countrycode}"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsTrue(); + } + + [Fact] + public void Should_match_the_header_with_placeholders() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "PL-V1", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^(?i)(?.+)-(?.+)$", "{header:countrycode}-{header:version}"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsTrue(); + } + + [Fact] + public void Should_not_match_the_header_with_placeholders() + { + // Arrange + var upstreamHeaders = new Dictionary() + { + ["anyHeader"] = "PL", + }; + var templateHeaders = new Dictionary() + { + ["anyHeader"] = new("^(?i)(?.+)-(?.+)$", "{header:countrycode}-{header:version}"), + }; + GivenIHaveUpstreamHeaders(upstreamHeaders); + GivenIHaveTemplateHeadersInRoute(templateHeaders); + + // Act + WhenIMatchTheHeaders(); + + // Assert + ThenTheResultIsFalse(); + } + + private void GivenIHaveUpstreamHeaders(Dictionary upstreamHeaders) + { + _upstreamHeaders = upstreamHeaders; + } + + private void GivenIHaveTemplateHeadersInRoute(Dictionary templateHeaders) + { + _templateHeaders = templateHeaders; + } + + private void WhenIMatchTheHeaders() + { + _result = _headerMatcher.Match(_upstreamHeaders, _templateHeaders); + } + + private void ThenTheResultIsTrue() + { + _result.ShouldBeTrue(); + } + + private void ThenTheResultIsFalse() + { + _result.ShouldBeFalse(); + } +}