diff --git a/docs/features/routing.rst b/docs/features/routing.rst index fa3bc0b74..354cc68f8 100644 --- a/docs/features/routing.rst +++ b/docs/features/routing.rst @@ -183,7 +183,7 @@ e.g. you could have "Priority": 0 } -and +and .. code-block:: json @@ -381,6 +381,53 @@ If needed, a more complex upstream header template can be specified using placeh **Note 2**: Additionally, the ``UpstreamHeaderTemplates`` dictionary options are applicable for :doc:`../features/aggregation` as well. + +Upstream Header-Based Routing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This feature was requested in `issue 360 `_ and `issue 624 `_. + +Ocelot allows you to define a Route with upstream headers, each of which may define a set of accepted values. If a Route has a set of upstream headers defined in it, it will no longer match a request's upstream path based solely on upstream path template. The request must also contain one or more headers required by the Route for a match. + +A sample configuration might look like the following: + +.. code-block:: json + + { + "Routes": [ + { + // Downstream* props + // Upstream* props + "UpstreamHeaderRoutingOptions": { + "Headers": { + "X-API-Version": [ "1" ], + "X-Tenant-Id": [ "tenantId" ] + }, + "TriggerOn": "all" + } + }, + { + // Downstream* props + // Upstream* props + "UpstreamHeaderRoutingOptions": { + "Headers": { + "X-API-Version": [ "1", "2" ] + }, + "TriggerOn": "any" + } + } + ] + } + +The ``UpstreamHeaderRoutingOptions`` block defines two attributes: the ``Headers`` block and the ``TriggerOn`` attribute. + +The ``Headers`` attribute defines required header names as keys and lists of acceptable header values as values. During route matching, both header names and values are matched in *case insensitive* manner. Please note that if a header has more than one acceptable value configured, presence of any of those values in a request is sufficient for a header to be a match. + +The second attribute, ``TriggerOn``, defines how the route finder will determine whether a particular header configuration in a request matches a Route's header configuration. The attribute accepts two values: + +* ``"Any"`` causes the route finder to match a Route if any value of *any* configured header is present in a request +* ``"All"`` causes the route finder to match a Route only if any value of *all* configured headers is present in a request + .. _routing-security-options: Security Options [#f6]_ diff --git a/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs b/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs index ad8f19b5a..d853510c0 100644 --- a/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs +++ b/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs @@ -1,4 +1,5 @@ -using Ocelot.Configuration.Creator; +using Microsoft.AspNetCore.Routing; +using Ocelot.Configuration.Creator; using Ocelot.Infrastructure.Extensions; using Ocelot.Values; @@ -40,6 +41,7 @@ public class DownstreamRouteBuilder private Dictionary _upstreamHeaders; private MetadataOptions _metadataOptions; private int? _timeout; + private UpstreamHeaderRoutingOptions _upstreamHeaderRoutingOptions; public DownstreamRouteBuilder() { @@ -253,6 +255,12 @@ public DownstreamRouteBuilder WithTimeout(int? timeout) return this; } + public DownstreamRouteBuilder WithUpstreamHeaderRoutingOptions(UpstreamHeaderRoutingOptions routingOptions) + { + _upstreamHeaderRoutingOptions = routingOptions; + return this; + } + public DownstreamRoute Build() { return new DownstreamRoute( diff --git a/src/Ocelot/Configuration/Creator/IUpstreamHeaderRoutingOptionsCreator.cs b/src/Ocelot/Configuration/Creator/IUpstreamHeaderRoutingOptionsCreator.cs new file mode 100644 index 000000000..bd26ad130 --- /dev/null +++ b/src/Ocelot/Configuration/Creator/IUpstreamHeaderRoutingOptionsCreator.cs @@ -0,0 +1,8 @@ +using Ocelot.Configuration.File; + +namespace Ocelot.Configuration.Creator; + +public interface IUpstreamHeaderRoutingOptionsCreator +{ + UpstreamHeaderRoutingOptions Create(FileUpstreamHeaderRoutingOptions options); +} diff --git a/src/Ocelot/Configuration/Creator/StaticRoutesCreator.cs b/src/Ocelot/Configuration/Creator/StaticRoutesCreator.cs index f3a841975..1f6d6ed74 100644 --- a/src/Ocelot/Configuration/Creator/StaticRoutesCreator.cs +++ b/src/Ocelot/Configuration/Creator/StaticRoutesCreator.cs @@ -23,6 +23,7 @@ public class StaticRoutesCreator : IRoutesCreator private readonly IVersionCreator _versionCreator; private readonly IVersionPolicyCreator _versionPolicyCreator; private readonly IMetadataCreator _metadataCreator; + private readonly IUpstreamHeaderRoutingOptionsCreator _upstreamHeaderRoutingOptionsCreator; public StaticRoutesCreator( IClaimsToThingCreator claimsToThingCreator, @@ -41,7 +42,8 @@ public StaticRoutesCreator( IVersionCreator versionCreator, IVersionPolicyCreator versionPolicyCreator, IUpstreamHeaderTemplatePatternCreator upstreamHeaderTemplatePatternCreator, - IMetadataCreator metadataCreator) + IMetadataCreator metadataCreator, + IUpstreamHeaderRoutingOptionsCreator upstreamHeaderRoutingOptionsCreator) { _routeKeyCreator = routeKeyCreator; _loadBalancerOptionsCreator = loadBalancerOptionsCreator; @@ -61,6 +63,7 @@ public StaticRoutesCreator( _versionPolicyCreator = versionPolicyCreator; _upstreamHeaderTemplatePatternCreator = upstreamHeaderTemplatePatternCreator; _metadataCreator = metadataCreator; + _upstreamHeaderRoutingOptionsCreator = upstreamHeaderRoutingOptionsCreator; } public IReadOnlyList Create(FileConfiguration fileConfiguration) @@ -83,6 +86,7 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf var requestIdKey = _requestIdKeyCreator.Create(fileRoute, globalConfiguration); var upstreamTemplatePattern = _upstreamTemplatePatternCreator.Create(fileRoute); + var upstreamHeaderRoutingOptions = _upstreamHeaderRoutingOptionsCreator.Create(fileRoute.UpstreamHeaderRoutingOptions); var authOptions = _authOptionsCreator.Create(fileRoute, globalConfiguration); @@ -151,6 +155,7 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf .WithUpstreamHeaderFindAndReplace(hAndRs.Upstream) .WithUpstreamHttpMethod(fileRoute.UpstreamHttpMethod.ToList()) .WithUpstreamPathTemplate(upstreamTemplatePattern) + .WithUpstreamHeaderRoutingOptions(upstreamHeaderRoutingOptions) .Build(); return route; } @@ -160,12 +165,14 @@ private Route SetUpRoute(FileRoute fileRoute, DownstreamRoute downstreamRoute) var upstreamTemplatePattern = _upstreamTemplatePatternCreator.Create(fileRoute); // TODO It should be downstreamRoute.UpstreamPathTemplate var upstreamHeaderTemplates = _upstreamHeaderTemplatePatternCreator.Create(fileRoute); // TODO It should be downstreamRoute.UpstreamHeaders var upstreamHttpMethods = fileRoute.UpstreamHttpMethod.ToHttpMethods(); + var upstreamHeaderRoutingOptions = _upstreamHeaderRoutingOptionsCreator.Create(fileRoute.UpstreamHeaderRoutingOptions); return new Route(downstreamRoute) { UpstreamHeaderTemplates = upstreamHeaderTemplates, // downstreamRoute.UpstreamHeaders UpstreamHost = fileRoute.UpstreamHost, UpstreamHttpMethod = upstreamHttpMethods, UpstreamTemplatePattern = upstreamTemplatePattern, + UpstreamHeaderRoutingOptions = upstreamHeaderRoutingOptions, }; } } diff --git a/src/Ocelot/Configuration/Creator/UpstreamHeaderRoutingOptionsCreator.cs b/src/Ocelot/Configuration/Creator/UpstreamHeaderRoutingOptionsCreator.cs new file mode 100644 index 000000000..4e9f766e5 --- /dev/null +++ b/src/Ocelot/Configuration/Creator/UpstreamHeaderRoutingOptionsCreator.cs @@ -0,0 +1,25 @@ +using Ocelot.Configuration.File; + +namespace Ocelot.Configuration.Creator; + +public class UpstreamHeaderRoutingOptionsCreator : IUpstreamHeaderRoutingOptionsCreator +{ + public UpstreamHeaderRoutingOptions Create(FileUpstreamHeaderRoutingOptions options) + { + var mode = UpstreamHeaderRoutingTriggerMode.Any; + if (options.TriggerOn.Length > 0) + { + mode = Enum.Parse(options.TriggerOn, true); + } + + // Keys are converted to uppercase as apparently that is the preferred + // approach according to https://learn.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings + // Values are left untouched but value comparison at runtime is done in + // a case-insensitive manner by using the appropriate StringComparer. + var headers = options.Headers.ToDictionary( + kv => kv.Key.ToUpperInvariant(), + kv => kv.Value); + + return new UpstreamHeaderRoutingOptions(headers, mode); + } +} diff --git a/src/Ocelot/Configuration/File/FileRoute.cs b/src/Ocelot/Configuration/File/FileRoute.cs index 796e527a3..89eab881d 100644 --- a/src/Ocelot/Configuration/File/FileRoute.cs +++ b/src/Ocelot/Configuration/File/FileRoute.cs @@ -1,10 +1,10 @@ -namespace Ocelot.Configuration.File; +namespace Ocelot.Configuration.File; /// /// Represents the JSON structure of a standard static route (no service discovery). /// public class FileRoute : FileRouteBase, IRouteUpstream, IRouteGrouping, IRouteRateLimiting, ICloneable -{ +{ public FileRoute() { AddClaimsToRequest = new Dictionary(); @@ -19,6 +19,7 @@ public FileRoute() SecurityOptions = new FileSecurityOptions(); UpstreamHeaderTemplates = new Dictionary(); UpstreamHeaderTransform = new Dictionary(); + UpstreamHeaderRoutingOptions = new FileUpstreamHeaderRoutingOptions(); UpstreamHttpMethod = new(); } @@ -44,6 +45,7 @@ public FileRoute(FileRoute from) public Dictionary RouteClaimsRequirement { get; set; } public bool RouteIsCaseSensitive { get; set; } public FileSecurityOptions SecurityOptions { get; set; } + public FileUpstreamHeaderRoutingOptions UpstreamHeaderRoutingOptions { get; set; } public IDictionary UpstreamHeaderTemplates { get; set; } public IDictionary UpstreamHeaderTransform { get; set; } public string UpstreamHost { get; set; } @@ -94,6 +96,7 @@ public static void DeepCopy(FileRoute from, FileRoute to) to.ServiceName = from.ServiceName; to.ServiceNamespace = from.ServiceNamespace; to.Timeout = from.Timeout; + to.UpstreamHeaderRoutingOptions = from.UpstreamHeaderRoutingOptions; to.UpstreamHeaderTemplates = new Dictionary(from.UpstreamHeaderTemplates); to.UpstreamHeaderTransform = new Dictionary(from.UpstreamHeaderTransform); to.UpstreamHost = from.UpstreamHost; @@ -114,5 +117,5 @@ public override string ToString() return !string.IsNullOrWhiteSpace(ServiceName) ? string.Join(':', ServiceNamespace, ServiceName, path) : path; - } + } } diff --git a/src/Ocelot/Configuration/File/FileUpstreamHeaderRoutingOptions.cs b/src/Ocelot/Configuration/File/FileUpstreamHeaderRoutingOptions.cs new file mode 100644 index 000000000..3aaa35320 --- /dev/null +++ b/src/Ocelot/Configuration/File/FileUpstreamHeaderRoutingOptions.cs @@ -0,0 +1,8 @@ +namespace Ocelot.Configuration.File; + +public class FileUpstreamHeaderRoutingOptions +{ + public IDictionary> Headers { get; set; } = new Dictionary>(); + + public string TriggerOn { get; set; } = string.Empty; +} diff --git a/src/Ocelot/Configuration/Route.cs b/src/Ocelot/Configuration/Route.cs index ed6f4df0d..742f31d5b 100644 --- a/src/Ocelot/Configuration/Route.cs +++ b/src/Ocelot/Configuration/Route.cs @@ -23,4 +23,5 @@ public Route(DownstreamRoute route, HttpMethod method) public string UpstreamHost { get; init; } public HashSet UpstreamHttpMethod { get; init; } public UpstreamPathTemplate UpstreamTemplatePattern { get; init; } + public UpstreamHeaderRoutingOptions UpstreamHeaderRoutingOptions { get; init; } } diff --git a/src/Ocelot/Configuration/UpstreamHeaderRoutingOptions.cs b/src/Ocelot/Configuration/UpstreamHeaderRoutingOptions.cs new file mode 100644 index 000000000..eac9ad9ad --- /dev/null +++ b/src/Ocelot/Configuration/UpstreamHeaderRoutingOptions.cs @@ -0,0 +1,16 @@ +namespace Ocelot.Configuration; + +public class UpstreamHeaderRoutingOptions +{ + public UpstreamHeaderRoutingOptions(IReadOnlyDictionary> headers, UpstreamHeaderRoutingTriggerMode mode) + { + Headers = new UpstreamRoutingHeaders(headers); + Mode = mode; + } + + public bool Enabled() => Headers.Any(); + + public UpstreamRoutingHeaders Headers { get; } + + public UpstreamHeaderRoutingTriggerMode Mode { get; } +} diff --git a/src/Ocelot/Configuration/UpstreamHeaderRoutingTriggerMode.cs b/src/Ocelot/Configuration/UpstreamHeaderRoutingTriggerMode.cs new file mode 100644 index 000000000..47a3e636e --- /dev/null +++ b/src/Ocelot/Configuration/UpstreamHeaderRoutingTriggerMode.cs @@ -0,0 +1,7 @@ +namespace Ocelot.Configuration; + +public enum UpstreamHeaderRoutingTriggerMode : byte +{ + Any, + All, +} diff --git a/src/Ocelot/Configuration/UpstreamRoutingHeaders.cs b/src/Ocelot/Configuration/UpstreamRoutingHeaders.cs new file mode 100644 index 000000000..d3c973d9c --- /dev/null +++ b/src/Ocelot/Configuration/UpstreamRoutingHeaders.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Ocelot.Configuration; + +public class UpstreamRoutingHeaders +{ + public IReadOnlyDictionary> Headers { get; } + + public UpstreamRoutingHeaders(IReadOnlyDictionary> headers) + { + Headers = headers; + } + + public bool Any() => Headers.Any(); + + public bool HasAnyOf(IHeaderDictionary requestHeaders) + { + IHeaderDictionary normalizedHeaders = NormalizeHeaderNames(requestHeaders); + foreach (var h in Headers) + { + if (normalizedHeaders.TryGetValue(h.Key, out var values) && + h.Value.Intersect(values, StringComparer.OrdinalIgnoreCase).Any()) + { + return true; + } + } + + return false; + } + + public bool HasAllOf(IHeaderDictionary requestHeaders) + { + IHeaderDictionary normalizedHeaders = NormalizeHeaderNames(requestHeaders); + foreach (var h in Headers) + { + if (!normalizedHeaders.TryGetValue(h.Key, out var values)) + { + return false; + } + + if (!h.Value.Intersect(values, StringComparer.OrdinalIgnoreCase).Any()) + { + return false; + } + } + + return true; + } + + private static IHeaderDictionary NormalizeHeaderNames(IHeaderDictionary headers) + { + var upperCaseHeaders = new HeaderDictionary(); + foreach (KeyValuePair kv in headers) + { + var key = kv.Key.ToUpperInvariant(); + upperCaseHeaders.Add(key, kv.Value); + } + + return upperCaseHeaders; + } +} diff --git a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs index b1123b87c..daafad3fb 100644 --- a/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs +++ b/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Ocelot.Configuration.File; using Ocelot.Errors; @@ -26,9 +26,9 @@ public FileConfigurationFluentValidator(IServiceProvider provider, RouteFluentVa RuleFor(configuration => configuration.GlobalConfiguration) .SetValidator(fileGlobalConfigurationFluentValidator); - RuleForEach(configuration => configuration.Routes) - .Must((config, route) => IsNotDuplicateIn(route, config.Routes)) - .WithMessage((_, route) => $"{nameof(route)} {route.UpstreamPathTemplate} has duplicate"); + RuleForEach(configuration => configuration.Routes) + .Must((config, route) => IsNotDuplicateIn(route, config.Routes)) + .WithMessage((_, route) => $"{nameof(route)} {route.UpstreamPathTemplate} has duplicate upstream path or routing header mapping"); RuleForEach(configuration => configuration.Routes) .Must((config, route) => HaveServiceDiscoveryProviderRegistered(route, config.GlobalConfiguration.ServiceDiscoveryProvider)) @@ -99,7 +99,7 @@ private static bool AllRoutesForAggregateExist(FileAggregateRoute fileAggregateR return routesForAggregate.Count() == fileAggregateRoute.RouteKeys.Count; } - + [GeneratedRegex(@"\{\w+\}", RegexOptions.IgnoreCase | RegexOptions.Singleline, RegexGlobal.DefaultMatchTimeoutMilliseconds, "en-US")] private static partial Regex PlaceholderRegex(); @@ -109,7 +109,7 @@ private static bool IsPlaceholderNotDuplicatedIn(string pathTemplate) .Select(m => m.Value).ToList(); return placeholders.Count == placeholders.Distinct().Count(); } - + private static bool DoesNotContainRoutesWithSpecificRequestIdKeys(FileAggregateRoute fileAggregateRoute, IEnumerable routes) { @@ -123,7 +123,8 @@ private static bool IsNotDuplicateIn(FileRoute route, IEnumerable rou var matchingRoutes = routes .Where(r => r.UpstreamPathTemplate == route.UpstreamPathTemplate && r.UpstreamHost == route.UpstreamHost - && AreTheSame(r.UpstreamHeaderTemplates, route.UpstreamHeaderTemplates)) + && AreTheSame(r.UpstreamHeaderTemplates, route.UpstreamHeaderTemplates) + && AreDuplicates(route.UpstreamHeaderRoutingOptions.Headers, r.UpstreamHeaderRoutingOptions.Headers)) .ToArray(); if (matchingRoutes.Length == 1) @@ -146,7 +147,7 @@ private static bool IsNotDuplicateIn(FileRoute route, IEnumerable rou 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); @@ -161,11 +162,46 @@ private static bool IsNotDuplicateIn(FileRoute route, return !duplicate; } - private static bool IsNotDuplicateIn(FileAggregateRoute route, IEnumerable aggregateRoutes) { var matchingRoutes = aggregateRoutes .Where(r => r.UpstreamPathTemplate == route.UpstreamPathTemplate & r.UpstreamHost == route.UpstreamHost); return matchingRoutes.Count() <= 1; - } + } + + private static bool AreDuplicates(IDictionary> first, IDictionary> second) + { + if (!first.Any() && !second.Any()) + { + return true; + } + + // if either of the two header collections is empty while the other is not, it's obvious that they can never be duplicate + if (first.Any() ^ second.Any()) + { + return false; + } + + var firstKeySet = first.Keys.Select(k => k.ToUpperInvariant()); + var secondKeySet = second.Keys.Select(k => k.ToUpperInvariant()); + if (!firstKeySet.Intersect(secondKeySet).Any()) + { + return false; + } + + foreach (var (key, firstValues) in first) + { + if (!second.TryGetValue(key, out var secondValues)) + { + continue; + } + + if (firstValues.Intersect(secondValues, StringComparer.OrdinalIgnoreCase).Any()) + { + return true; + } + } + + return false; + } } diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index ff343fafe..3a99fc4ce 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -69,6 +69,7 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); + Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); diff --git a/src/Ocelot/DownstreamRouteFinder/Finder/DiscoveryDownstreamRouteFinder.cs b/src/Ocelot/DownstreamRouteFinder/Finder/DiscoveryDownstreamRouteFinder.cs index 02d4c05b1..a7209d9db 100644 --- a/src/Ocelot/DownstreamRouteFinder/Finder/DiscoveryDownstreamRouteFinder.cs +++ b/src/Ocelot/DownstreamRouteFinder/Finder/DiscoveryDownstreamRouteFinder.cs @@ -1,4 +1,5 @@ -using Ocelot.Configuration; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Configuration.Creator; using Ocelot.DownstreamRouteFinder.UrlMatcher; @@ -28,7 +29,7 @@ public DiscoveryDownstreamRouteFinder( } public Response Get(string upstreamUrlPath, string upstreamQueryString, string upstreamHttpMethod, - IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders) + IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders, IHeaderDictionary requestHeaders) { var serviceName = GetServiceName(upstreamUrlPath, out var serviceNamespace); var downstreamPath = GetDownstreamPath(upstreamUrlPath); diff --git a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs index 24c1d4169..a708e190f 100644 --- a/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs +++ b/src/Ocelot/DownstreamRouteFinder/Finder/DownstreamRouteFinder.cs @@ -1,7 +1,8 @@ -using Ocelot.Configuration; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; using Ocelot.DownstreamRouteFinder.HeaderMatcher; using Ocelot.DownstreamRouteFinder.UrlMatcher; -using Ocelot.Responses; +using Ocelot.Responses; namespace Ocelot.DownstreamRouteFinder.Finder; @@ -25,12 +26,12 @@ public DownstreamRouteFinder( } public Response Get(string upstreamUrlPath, string upstreamQueryString, string httpMethod, - IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders) + IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders, IHeaderDictionary requestHeaders) { var downstreamRoutes = new List(); var applicableRoutes = configuration.Routes - .Where(r => !r.IsDynamic && RouteIsApplicableToThisRequest(r, httpMethod, upstreamHost)) // process static routes only + .Where(r => !r.IsDynamic && RouteIsApplicableToThisRequest(r, httpMethod, upstreamHost, requestHeaders)) // process static routes only .OrderByDescending(x => x.UpstreamTemplatePattern.Priority); foreach (var route in applicableRoutes) @@ -42,7 +43,7 @@ public Response Get(string upstreamUrlPath, string upstre downstreamRoutes.Add(GetPlaceholderNamesAndValues(upstreamUrlPath, upstreamQueryString, route, upstreamHeaders)); } } - + if (downstreamRoutes.Count != 0) { var notNullOption = downstreamRoutes.FirstOrDefault(x => !string.IsNullOrEmpty(x.Route.UpstreamHost)); @@ -61,6 +62,19 @@ private static bool RouteIsApplicableToThisRequest(Route route, string httpMetho (string.IsNullOrEmpty(route.UpstreamHost) || route.UpstreamHost == upstreamHost); } + private static bool RouteIsApplicableToThisRequest(Route route, string httpMethod, string upstreamHost, IHeaderDictionary requestHeaders) + => (route.UpstreamHttpMethod.Count == 0 || RouteHasHttpMethod(route, httpMethod)) && + (string.IsNullOrEmpty(route.UpstreamHost) || route.UpstreamHost == upstreamHost) && + (route.UpstreamHeaderRoutingOptions?.Enabled() != true || RequiredUpstreamHeadersArePresent(route.UpstreamHeaderRoutingOptions, requestHeaders)); + + private static bool RouteHasHttpMethod(Route route, string httpMethod) => + route.UpstreamHttpMethod.Contains(new HttpMethod(httpMethod)); + + private static bool RequiredUpstreamHeadersArePresent(UpstreamHeaderRoutingOptions options, IHeaderDictionary requestHeaders) => + options.Mode == UpstreamHeaderRoutingTriggerMode.Any + ? options.Headers.HasAnyOf(requestHeaders) + : options.Headers.HasAllOf(requestHeaders); + private DownstreamRouteHolder GetPlaceholderNamesAndValues(string path, string query, Route route, IDictionary upstreamHeaders) { var templatePlaceholderNameAndValues = _pathPlaceholderFinder diff --git a/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs b/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs index c30ba31bc..40ad8a3a4 100644 --- a/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs +++ b/src/Ocelot/DownstreamRouteFinder/Finder/IDownstreamRouteProvider.cs @@ -1,10 +1,11 @@ -using Ocelot.Configuration; +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; using Ocelot.Responses; - + namespace Ocelot.DownstreamRouteFinder.Finder; public interface IDownstreamRouteProvider { Response Get(string upstreamUrlPath, string upstreamQueryString, string upstreamHttpMethod, - IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders); + IInternalConfiguration configuration, string upstreamHost, IDictionary upstreamHeaders, IHeaderDictionary requestHeaders); } diff --git a/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs b/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs index 41a44ec09..22060194c 100644 --- a/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs +++ b/src/Ocelot/DownstreamRouteFinder/Middleware/DownstreamRouteFinderMiddleware.cs @@ -35,7 +35,8 @@ public async Task Invoke(HttpContext httpContext) Logger.LogDebug(() => $"Upstream URL path: {upstreamUrlPath}"); var provider = _factory.Get(internalConfiguration); - var response = provider.Get(upstreamUrlPath, upstreamQueryString, httpContext.Request.Method, internalConfiguration, upstreamHost, upstreamHeaders); + var requestHeaders = httpContext.Request.Headers; + var response = provider.Get(upstreamUrlPath, upstreamQueryString, httpContext.Request.Method, internalConfiguration, upstreamHost, upstreamHeaders, requestHeaders); if (response.IsError) { Logger.LogWarning(() => $"{MiddlewareName} setting pipeline errors because {provider.GetType().Name} returned the following ->{response.Errors.ToErrorString(true)}"); diff --git a/test/Ocelot.UnitTests/Configuration/StaticRoutesCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/StaticRoutesCreatorTests.cs index 7bbacd10f..6cf54fac3 100644 --- a/test/Ocelot.UnitTests/Configuration/StaticRoutesCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/StaticRoutesCreatorTests.cs @@ -44,6 +44,7 @@ public class StaticRoutesCreatorTests : UnitTest private HttpVersionPolicy _expectedVersionPolicy; private Dictionary _uht; private Dictionary _expectedMetadata; + private readonly Mock _uhroCreator; public StaticRoutesCreatorTests() { @@ -64,6 +65,7 @@ public StaticRoutesCreatorTests() _versionPolicyCreator = new Mock(); _uhtpCreator = new Mock(); _metadataCreator = new Mock(); + _uhroCreator = new Mock(); _creator = new StaticRoutesCreator( _cthCreator.Object, @@ -82,7 +84,8 @@ public StaticRoutesCreatorTests() _versionCreator.Object, _versionPolicyCreator.Object, _uhtpCreator.Object, - _metadataCreator.Object); + _metadataCreator.Object, + _uhroCreator.Object); } [Fact] diff --git a/test/Ocelot.UnitTests/Configuration/UpstreamHeaderRoutingOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/UpstreamHeaderRoutingOptionsCreatorTests.cs new file mode 100644 index 000000000..bf8925a81 --- /dev/null +++ b/test/Ocelot.UnitTests/Configuration/UpstreamHeaderRoutingOptionsCreatorTests.cs @@ -0,0 +1,59 @@ +using Ocelot.Configuration; +using Ocelot.Configuration.Creator; +using Ocelot.Configuration.File; + +namespace Ocelot.UnitTests.Configuration; + +public class UpstreamHeaderRoutingOptionsCreatorTests +{ + private FileUpstreamHeaderRoutingOptions _fileUpstreamHeaderRoutingOptions; + private readonly IUpstreamHeaderRoutingOptionsCreator _creator = new UpstreamHeaderRoutingOptionsCreator(); + private UpstreamHeaderRoutingOptions _upstreamHeaderRoutingOptions; + + [Fact] + public void Should_create_upstream_routing_header_options() + { + UpstreamHeaderRoutingOptions expected = new( + headers: new Dictionary>() + { + { "HEADER1", new[] { "Value1", "Value2" }}, + { "HEADER2", new[] { "Value3" }}, + }, + mode: UpstreamHeaderRoutingTriggerMode.All + ); + + GivenTheseFileUpstreamHeaderRoutingOptions(); + WhenICreate(); + ThenTheCreatedMatchesThis(expected); + } + + private void GivenTheseFileUpstreamHeaderRoutingOptions() + { + _fileUpstreamHeaderRoutingOptions = new FileUpstreamHeaderRoutingOptions() + { + Headers = new Dictionary>() + { + { "Header1", new[] { "Value1", "Value2" }}, + { "Header2", new[] { "Value3" }}, + }, + TriggerOn = "all", + }; + } + + private void WhenICreate() + { + _upstreamHeaderRoutingOptions = _creator.Create(_fileUpstreamHeaderRoutingOptions); + } + + private void ThenTheCreatedMatchesThis(UpstreamHeaderRoutingOptions expected) + { + _upstreamHeaderRoutingOptions.Headers.Headers.Count.ShouldBe(expected.Headers.Headers.Count); + foreach (var pair in _upstreamHeaderRoutingOptions.Headers.Headers) + { + expected.Headers.Headers.TryGetValue(pair.Key, out var expectedValue).ShouldBe(true); + expectedValue.ShouldBeEquivalentTo(pair.Value); + } + + _upstreamHeaderRoutingOptions.Mode.ShouldBe(expected.Mode); + } +} diff --git a/test/Ocelot.UnitTests/Configuration/UpstreamRoutingHeadersTests.cs b/test/Ocelot.UnitTests/Configuration/UpstreamRoutingHeadersTests.cs new file mode 100644 index 000000000..872917259 --- /dev/null +++ b/test/Ocelot.UnitTests/Configuration/UpstreamRoutingHeadersTests.cs @@ -0,0 +1,131 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Ocelot.Configuration; + +namespace Ocelot.UnitTests.Configuration; + +public class UpstreamRoutingHeadersTests +{ + private IReadOnlyDictionary> _headersDictionary; + private UpstreamRoutingHeaders _upstreamRoutingHeaders; + private IHeaderDictionary _requestHeaders; + + [Fact] + public void Should_create_empty_headers() + { + GivenEmptyHeaderDictionary(); + WhenICreate(); + ThenAnyIs(false); + } + + [Fact] + public void Should_create_preset_headers() + { + GivenPresetHeaderDictionary(); + WhenICreate(); + ThenAnyIs(true); + } + + [Fact] + public void Should_not_match_mismatching_request_headers() + { + GivenPresetHeaderDictionary(); + AndGivenMismatchingRequestHeaders(); + WhenICreate(); + ThenHasAnyOfIs(false); + ThenHasAllOfIs(false); + } + + [Fact] + public void Should_not_match_matching_header_with_mismatching_value() + { + GivenPresetHeaderDictionary(); + AndGivenOneMatchingHeaderWithMismatchingValue(); + WhenICreate(); + ThenHasAnyOfIs(false); + ThenHasAllOfIs(false); + } + + [Fact] + public void Should_match_any_header_not_all() + { + GivenPresetHeaderDictionary(); + AndGivenOneMatchingHeaderWithMatchingValue(); + WhenICreate(); + ThenHasAnyOfIs(true); + ThenHasAllOfIs(false); + } + + [Fact] + public void Should_match_any_and_all_headers() + { + GivenPresetHeaderDictionary(); + AndGivenTwoMatchingHeadersWithMatchingValues(); + WhenICreate(); + ThenHasAnyOfIs(true); + ThenHasAllOfIs(true); + } + + private void GivenEmptyHeaderDictionary() + { + _headersDictionary = new Dictionary>(); + } + + private void GivenPresetHeaderDictionary() + { + _headersDictionary = new Dictionary>() + { + { "testheader1", new HashSet() { "testheader1value1", "testheader1value2" } }, + { "testheader2", new HashSet() { "testheader1Value1", "testheader2value2" } }, + }; + } + + private void AndGivenMismatchingRequestHeaders() + { + _requestHeaders = new HeaderDictionary() { + { "someHeader", new StringValues(new []{ "someHeaderValue" })}, + }; + } + + private void AndGivenOneMatchingHeaderWithMismatchingValue() + { + _requestHeaders = new HeaderDictionary() { + { "testHeader1", new StringValues(new []{ "mismatchingValue" })}, + }; + } + + private void AndGivenOneMatchingHeaderWithMatchingValue() + { + _requestHeaders = new HeaderDictionary() { + { "testHeader1", new StringValues(new []{ "testHeader1Value1" })}, + }; + } + + private void AndGivenTwoMatchingHeadersWithMatchingValues() + { + _requestHeaders = new HeaderDictionary() { + { "testHeader1", new StringValues(new []{ "testHeader1Value1", "bogusValue" })}, + { "testHeader2", new StringValues(new []{ "bogusValue", "testHeader2Value2" })}, + }; + } + + private void WhenICreate() + { + _upstreamRoutingHeaders = new UpstreamRoutingHeaders(_headersDictionary); + } + + private void ThenAnyIs(bool expected) + { + _upstreamRoutingHeaders.Any().ShouldBe(expected); + } + + private void ThenHasAnyOfIs(bool expected) + { + _upstreamRoutingHeaders.HasAnyOf(_requestHeaders).ShouldBe(expected); + } + + private void ThenHasAllOfIs(bool expected) + { + _upstreamRoutingHeaders.HasAllOf(_requestHeaders).ShouldBe(expected); + } +} diff --git a/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs b/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs index 382026946..77aeb517c 100644 --- a/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/Validation/FileConfigurationFluentValidatorTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -545,7 +545,7 @@ public async Task Configuration_is_not_valid_with_duplicate_routes_all_verbs() // Assert ThenTheResultIsNotValid(); - ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate"); + ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate upstream path or routing header mapping"); } [Fact] @@ -579,7 +579,7 @@ public async Task Configuration_is_not_valid_with_duplicate_routes_specific_verb // Assert ThenTheResultIsNotValid(); - ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate"); + ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate upstream path or routing header mapping"); } [Fact] @@ -615,7 +615,7 @@ public async Task Configuration_is_not_valid_with_duplicate_routes_with_duplicat // Assert ThenTheResultIsNotValid(); - ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate"); + ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate upstream path or routing header mapping"); } [Fact] @@ -709,7 +709,7 @@ public async Task Configuration_is_valid_with_using_service_discovery_and_servic // Assert ThenTheResultIsValid(); } - + private const string Empty = ""; [Theory] @@ -729,7 +729,7 @@ public async Task Configuration_is_invalid_when_not_using_service_discovery_and_ ThenTheResultIsNotValid(); 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!"); } - + [Theory] [InlineData(null, true)] [InlineData(Empty, true)] @@ -892,7 +892,7 @@ public async Task Configuration_is_not_valid_when_upstream_headers_the_same() // Assert ThenTheResultIsNotValid(); - ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate"); + ThenTheErrorMessageAtPositionIs(0, "route /asdf/ has duplicate upstream path or routing header mapping"); } [Fact] @@ -1076,7 +1076,7 @@ public async Task Configuration_is_valid_if_all_specified_authentication_provide ThenTheResultIsValid(); } #endregion - + private static FileRoute GivenDefaultRoute() => GivenDefaultRoute(null, null, null); private static FileRoute GivenDefaultRoute(string upstream, string downstream) => GivenDefaultRoute(upstream, downstream, null); @@ -1100,13 +1100,13 @@ public async Task Configuration_is_valid_if_all_specified_authentication_provide 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 = [HttpMethods.Get], @@ -1122,7 +1122,7 @@ private FileConfiguration GivenAConfiguration(params FileRoute[] routes) _fileConfiguration = config; return config; } - + private void GivenConfigurationWithAuthenticationKey(string key) { _fileConfiguration = new FileConfiguration(); @@ -1164,9 +1164,9 @@ private void ThenThereAreErrors(bool isError) private void ThenTheErrorMessagesAre(IEnumerable messages) { _result.Data.Errors.Count.ShouldBe(messages.Count()); - + foreach (var msg in messages) - { + { _result.Data.Errors.ShouldContain(e => e.Message == msg); } } diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs index c11dca83a..72d9d2eb3 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DiscoveryDownstreamRouteFinderTests.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Configuration.Creator; @@ -28,6 +29,7 @@ public class DiscoveryDownstreamRouteFinderTests : UnitTest private readonly HttpHandlerOptions _handlerOptions; private readonly MetadataOptions _metadataOptions; private readonly RateLimitOptions _rateLimitOptions; + private readonly IHeaderDictionary _requestHeaders; public DiscoveryDownstreamRouteFinderTests() { @@ -38,6 +40,7 @@ public DiscoveryDownstreamRouteFinderTests() _rateLimitOptions = new RateLimitOptions(); _finder = new(new RouteKeyCreator(), _upstreamHeaderTemplatePatternCreator.Object); _upstreamQuery = string.Empty; + _requestHeaders = new HeaderDictionary(); } [Fact] @@ -419,11 +422,11 @@ private void GivenInternalConfiguration(Route route = null, int index = 0) private void WhenICreate() { - _result = _finder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost, _upstreamHeaders); + _result = _finder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost, _upstreamHeaders, _requestHeaders); } private void WhenICreateAgain() { - _resultTwo = _finder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost, _upstreamHeaders); + _resultTwo = _finder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _configuration, _upstreamHost, _upstreamHeaders, _requestHeaders); } } diff --git a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs index 3feb3a629..a5ed8c7ee 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderMiddlewareTests.cs @@ -67,7 +67,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(), It.IsAny>())) + .Setup(x => x.Get(It.IsAny(), 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 4ba510667..b368c46ae 100644 --- a/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs +++ b/test/Ocelot.UnitTests/DownstreamRouteFinder/DownstreamRouteFinderTests.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.DownstreamRouteFinder; @@ -26,6 +27,8 @@ public class DownstreamRouteFinderTests : UnitTest private string _upstreamHost; private Dictionary _upstreamHeaders; private string _upstreamQuery; + private UpstreamHeaderRoutingOptions _upstreamHeaderRoutingOptions; + private readonly HeaderDictionary _requestHeaders; public DownstreamRouteFinderTests() { @@ -34,6 +37,7 @@ public DownstreamRouteFinderTests() _urlPlaceholderFinder = new Mock(); _headerPlaceholderFinder = new Mock(); _routeFinder = new _DownstreamRouteFinder_(_mockUrlMatcher.Object, _urlPlaceholderFinder.Object, _mockHeadersMatcher.Object, _headerPlaceholderFinder.Object); + _requestHeaders = new(); } [Fact] @@ -57,7 +61,7 @@ public void Should_return_highest_priority_when_first() _upstreamHttpMethod = "Post"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -86,7 +90,7 @@ public void Should_return_highest_priority_when_lowest() _upstreamHttpMethod = "Post"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -113,7 +117,7 @@ public void Should_return_route() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -142,7 +146,7 @@ public void Should_not_append_slash_to_upstream_url_path() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -172,7 +176,7 @@ public void Should_return_route_if_upstream_path_and_upstream_template_are_the_s _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -200,7 +204,7 @@ public void Should_return_correct_route_for_http_verb() _upstreamHttpMethod = "Post"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -226,7 +230,7 @@ public void Should_not_return_route() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert _result.IsError.ShouldBeTrue(); @@ -252,7 +256,7 @@ public void Should_return_correct_route_for_http_verb_setting_multiple_upstream_ _upstreamHttpMethod = "Post"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -279,7 +283,7 @@ public void Should_return_correct_route_for_http_verb_setting_all_upstream_http_ _upstreamHttpMethod = "Post"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -306,7 +310,7 @@ public void Should_not_return_route_for_http_verb_not_setting_in_upstream_http_m _upstreamHttpMethod = "Post"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert _result.IsError.ShouldBeTrue(); @@ -333,7 +337,7 @@ public void Should_return_route_when_host_matches() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -362,7 +366,7 @@ public void Should_return_route_when_upstreamhost_is_null() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -392,7 +396,7 @@ public void Should_not_return_route_when_host_doesnt_match() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert _result.IsError.ShouldBeTrue(); @@ -419,7 +423,7 @@ public void Should_not_return_route_when_host_doesnt_match_with_empty_upstream_h _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert _result.IsError.ShouldBeTrue(); @@ -446,7 +450,7 @@ public void Should_return_route_when_host_does_match_with_empty_upstream_http_me _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheUrlMatcherIsCalledCorrectly(1, 0); @@ -473,7 +477,7 @@ public void Should_return_route_when_host_matches_but_null_host_on_same_path_fir _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert ThenTheFollowingIsReturned(new( @@ -482,7 +486,7 @@ public void Should_return_route_when_host_matches_but_null_host_on_same_path_fir ThenTheUrlMatcherIsCalledCorrectly(1, 0); ThenTheUrlMatcherIsCalledCorrectly(1, 1); } - + [Fact] [Trait("PR", "1312")] [Trait("Feat", "360")] @@ -518,15 +522,15 @@ public void Should_return_route_when_upstream_headers_match() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); - + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); + // Assert ThenTheFollowingIsReturned(new( urlPlaceholders.Union(headerPlaceholders).ToList(), GivenRoute(priority: 1))); ThenTheUrlMatcherIsCalledCorrectly(); } - + [Fact] [Trait("PR", "1312")] [Trait("Feat", "360")] @@ -555,7 +559,7 @@ public void Should_not_return_route_when_upstream_headers_dont_match() _upstreamHttpMethod = "Get"; // Act - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); // Assert _result.IsError.ShouldBeTrue(); @@ -585,17 +589,92 @@ public void Should_filter_static_routes(bool isDynamic) _upstreamHttpMethod = "Get"; // Act, Assert - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); _result.Data.Route.IsDynamic.ShouldBeFalse(); // Act, Assert 2 _routesConfig.RemoveAll(r => !r.IsDynamic); // remove all static routes GivenTheConfigurationIs(string.Empty, serviceProviderConfig); - _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders); + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); _result.IsError.ShouldBeTrue(); } - private static Route GivenRoute(bool? isDynamic = null, string downstream = null, + private void GivenUpstreamHeaderRoutingOptionsEnabled() + { + _upstreamUrlPath = "matchInUrlMatcher/"; + GivenTheTemplateVariableAndNameFinderReturns(new OkResponse>(new())); + GivenUpstreamHeaderRoutingOptions(); + _routesConfig = new() + { + GivenRoute(priority: 1), + }; + GivenTheConfigurationIs(string.Empty, new ServiceProviderConfigurationBuilder().Build()); + GivenTheUrlMatcherReturns(new UrlMatch(true)); + _upstreamHttpMethod = "Get"; + } + + [Fact] + public void Should_not_return_route_with_upstream_header_routing_options_enabled_and_no_request_headers() + { + GivenUpstreamHeaderRoutingOptionsEnabled(); + WhenICallTheFinder(); + ThenAnErrorResponseIsReturned(); + } + + [Fact] + public void Should_not_return_route_with_upstream_header_routing_options_enabled_and_non_matching_request_headers() + { + GivenUpstreamHeaderRoutingOptionsEnabled(); + GivenNonEmptyNonMatchingRequestHeaders(); + WhenICallTheFinder(); + ThenAnErrorResponseIsReturned(); + } + + [Fact] + public void Should_return_route_with_upstream_header_routing_options_enabled_and_matching_request_headers() + { + GivenUpstreamHeaderRoutingOptionsEnabled(); + GivenNonEmptyMatchingRequestHeaders(); + + WhenICallTheFinder(); + + // Assert + ThenTheFollowingIsReturned(new( + new List(), + GivenRoute(priority: 1))); + ThenTheUrlMatcherIsCalledCorrectly(); + } + + private void GivenUpstreamHeaderRoutingOptions() + { + var headers = new Dictionary>() + { + { "header", ["value"] }, + }; + _upstreamHeaderRoutingOptions = new UpstreamHeaderRoutingOptions(headers, UpstreamHeaderRoutingTriggerMode.All); + } + + private void GivenNonEmptyNonMatchingRequestHeaders() + { + _requestHeaders.Add("header", new StringValues(["mismatch"])); + } + + private void GivenNonEmptyMatchingRequestHeaders() + { + _requestHeaders.Add("header", new StringValues(["value"])); + } + + private void WhenICallTheFinder() + { + _result = _routeFinder.Get(_upstreamUrlPath, _upstreamQuery, _upstreamHttpMethod, _config, _upstreamHost, _upstreamHeaders, _requestHeaders); + } + + private void ThenAnErrorResponseIsReturned() + { + _result.IsError.ShouldBeTrue(); + } + + private Route GivenRoute(bool? isDynamic = null, string downstream = null, List upstreamMethods = null, string method = null, UpstreamPathTemplate upTemplate = null, string upstream = null, int? priority = null, string host = null, @@ -612,6 +691,7 @@ private static Route GivenRoute(bool? isDynamic = null, string downstream = null UpstreamTemplatePattern = upTemplate, UpstreamHost = host, UpstreamHeaderTemplates = headers, + UpstreamHeaderRoutingOptions = _upstreamHeaderRoutingOptions, }; } @@ -663,7 +743,7 @@ private void GivenTheUrlMatcherReturns(UrlMatch match) .Setup(x => x.Match(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(_match); } - + private void GivenTheHeadersMatcherReturns(bool headersMatch) { _mockHeadersMatcher