diff --git a/README.md b/README.md index 5745ee79..8330c331 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,25 @@ Priorities TBC really but example configuration for a route below. ReRoutes: -- DownstreamTemplate: http://localhost:51876/ +# the url we are forwarding the request to +- DownstreamTemplate: http://localhost:52876/ +# the path we are listening on for this re route UpstreamTemplate: / - UpstreamHttpMethod: Post +# the method we are listening for on this re route + UpstreamHttpMethod: Get +# only support identity server at the moment AuthenticationOptions: Provider: IdentityServer - ProviderRootUrl: http://localhost:51888 + ProviderRootUrl: http://localhost:52888 ScopeName: api - AdditionalScopes: [] + AdditionalScopes: + - openid + - offline_access +#require if using reference tokens ScopeSecret: secret +# WARNING - will overwrite any headers already in the request with these values + AddHeadersToRequest: + CustomerId: Claims[CustomerId] > value + LocationId: Claims[LocationId] > value + UserType: Claims[sub] > value[0] > | + UserId: Claims[sub] > value[1] > | diff --git a/src/Ocelot.Library/Builder/ReRouteBuilder.cs b/src/Ocelot.Library/Builder/ReRouteBuilder.cs index c4ff124a..5668f15e 100644 --- a/src/Ocelot.Library/Builder/ReRouteBuilder.cs +++ b/src/Ocelot.Library/Builder/ReRouteBuilder.cs @@ -1,4 +1,6 @@ -namespace Ocelot.Library.Builder +using Ocelot.Library.RequestBuilder; + +namespace Ocelot.Library.Builder { using System.Collections.Generic; using Configuration; @@ -16,6 +18,7 @@ private List _additionalScopes; private bool _requireHttps; private string _scopeSecret; + private List _configHeaderExtractorProperties; public ReRouteBuilder() { @@ -86,9 +89,15 @@ return this; } + public ReRouteBuilder WithConfigurationHeaderExtractorProperties(List input) + { + _configHeaderExtractorProperties = input; + return this; + } + public ReRoute Build() { - return new ReRoute(_downstreamTemplate, _upstreamTemplate, _upstreamHttpMethod, _upstreamTemplatePattern, _isAuthenticated, new AuthenticationOptions(_authenticationProvider, _authenticationProviderUrl, _scopeName, _requireHttps, _additionalScopes, _scopeSecret)); + return new ReRoute(_downstreamTemplate, _upstreamTemplate, _upstreamHttpMethod, _upstreamTemplatePattern, _isAuthenticated, new AuthenticationOptions(_authenticationProvider, _authenticationProviderUrl, _scopeName, _requireHttps, _additionalScopes, _scopeSecret), _configHeaderExtractorProperties); } } } diff --git a/src/Ocelot.Library/Configuration/OcelotConfiguration.cs b/src/Ocelot.Library/Configuration/OcelotConfiguration.cs index b49f193a..d149baac 100644 --- a/src/Ocelot.Library/Configuration/OcelotConfiguration.cs +++ b/src/Ocelot.Library/Configuration/OcelotConfiguration.cs @@ -1,3 +1,8 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Ocelot.Library.RequestBuilder; + namespace Ocelot.Library.Configuration { using System.Collections.Generic; @@ -11,11 +16,18 @@ namespace Ocelot.Library.Configuration private readonly List _reRoutes; private const string RegExMatchEverything = ".*"; private const string RegExMatchEndString = "$"; + private readonly IConfigurationHeaderExtrator _configurationHeaderExtrator; + private readonly ILogger _logger; - public OcelotConfiguration(IOptions options, IConfigurationValidator configurationValidator) + public OcelotConfiguration(IOptions options, + IConfigurationValidator configurationValidator, + IConfigurationHeaderExtrator configurationHeaderExtrator, + ILogger logger) { _options = options; _configurationValidator = configurationValidator; + _configurationHeaderExtrator = configurationHeaderExtrator; + _logger = logger; _reRoutes = new List(); SetUpConfiguration(); } @@ -43,7 +55,7 @@ namespace Ocelot.Library.Configuration var placeholders = new List(); - for (int i = 0; i < upstreamTemplate.Length; i++) + for (var i = 0; i < upstreamTemplate.Length; i++) { if (IsPlaceHolder(upstreamTemplate, i)) { @@ -70,17 +82,41 @@ namespace Ocelot.Library.Configuration reRoute.AuthenticationOptions.RequireHttps, reRoute.AuthenticationOptions.AdditionalScopes, reRoute.AuthenticationOptions.ScopeSecret); + var configHeaders = GetHeadersToAddToRequest(reRoute); + _reRoutes.Add(new ReRoute(reRoute.DownstreamTemplate, reRoute.UpstreamTemplate, - reRoute.UpstreamHttpMethod, upstreamTemplate, isAuthenticated, authOptionsForRoute + reRoute.UpstreamHttpMethod, upstreamTemplate, isAuthenticated, + authOptionsForRoute, configHeaders )); } else { _reRoutes.Add(new ReRoute(reRoute.DownstreamTemplate, reRoute.UpstreamTemplate, reRoute.UpstreamHttpMethod, - upstreamTemplate, isAuthenticated, null)); + upstreamTemplate, isAuthenticated, null, new List())); } } + private List GetHeadersToAddToRequest(YamlReRoute reRoute) + { + var configHeaders = new List(); + + foreach (var add in reRoute.AddHeadersToRequest) + { + var configurationHeader = _configurationHeaderExtrator.Extract(add.Key, add.Value); + + if (configurationHeader.IsError) + { + _logger.LogCritical(new EventId(1, "Application Failed to start"), + $"Unable to extract configuration for key: {add.Key} and value: {add.Value} your configuration file is incorrect"); + + throw new Exception(configurationHeader.Errors[0].Message); + } + configHeaders.Add(configurationHeader.Data); + } + + return configHeaders; + } + private bool IsPlaceHolder(string upstreamTemplate, int i) { return upstreamTemplate[i] == '{'; diff --git a/src/Ocelot.Library/Configuration/ReRoute.cs b/src/Ocelot.Library/Configuration/ReRoute.cs index 8e73a689..a94913ae 100644 --- a/src/Ocelot.Library/Configuration/ReRoute.cs +++ b/src/Ocelot.Library/Configuration/ReRoute.cs @@ -1,8 +1,11 @@ -namespace Ocelot.Library.Configuration +using System.Collections.Generic; +using Ocelot.Library.RequestBuilder; + +namespace Ocelot.Library.Configuration { public class ReRoute { - public ReRoute(string downstreamTemplate, string upstreamTemplate, string upstreamHttpMethod, string upstreamTemplatePattern, bool isAuthenticated, AuthenticationOptions authenticationOptions) + public ReRoute(string downstreamTemplate, string upstreamTemplate, string upstreamHttpMethod, string upstreamTemplatePattern, bool isAuthenticated, AuthenticationOptions authenticationOptions, List configurationHeaderExtractorProperties) { DownstreamTemplate = downstreamTemplate; UpstreamTemplate = upstreamTemplate; @@ -10,6 +13,8 @@ UpstreamTemplatePattern = upstreamTemplatePattern; IsAuthenticated = isAuthenticated; AuthenticationOptions = authenticationOptions; + ConfigurationHeaderExtractorProperties = configurationHeaderExtractorProperties + ?? new List(); } public string DownstreamTemplate { get; private set; } @@ -18,5 +23,6 @@ public string UpstreamHttpMethod { get; private set; } public bool IsAuthenticated { get; private set; } public AuthenticationOptions AuthenticationOptions { get; private set; } + public List ConfigurationHeaderExtractorProperties { get; private set; } } } \ No newline at end of file diff --git a/src/Ocelot.Library/Configuration/Yaml/YamlReRoute.cs b/src/Ocelot.Library/Configuration/Yaml/YamlReRoute.cs index ed3410b5..675044a3 100644 --- a/src/Ocelot.Library/Configuration/Yaml/YamlReRoute.cs +++ b/src/Ocelot.Library/Configuration/Yaml/YamlReRoute.cs @@ -13,6 +13,6 @@ public string UpstreamTemplate { get; set; } public string UpstreamHttpMethod { get; set; } public YamlAuthenticationOptions AuthenticationOptions { get; set; } - public Dictionary AddHeadersToRequest { get; set; } + public Dictionary AddHeadersToRequest { get; set; } } } \ No newline at end of file diff --git a/src/Ocelot.Library/DependencyInjection/ServiceCollectionExtensions.cs b/src/Ocelot.Library/DependencyInjection/ServiceCollectionExtensions.cs index ad627355..2bcdf6a6 100644 --- a/src/Ocelot.Library/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Ocelot.Library/DependencyInjection/ServiceCollectionExtensions.cs @@ -21,6 +21,9 @@ services.Configure(configurationRoot); // Add framework services. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Ocelot.Library/Errors/OcelotErrorCode.cs b/src/Ocelot.Library/Errors/OcelotErrorCode.cs index dbe58dbe..643f00ef 100644 --- a/src/Ocelot.Library/Errors/OcelotErrorCode.cs +++ b/src/Ocelot.Library/Errors/OcelotErrorCode.cs @@ -10,6 +10,10 @@ CannotFindDataError, UnableToCompleteRequestError, UnableToCreateAuthenticationHandlerError, - UnsupportedAuthenticationProviderError + UnsupportedAuthenticationProviderError, + CannotFindClaimError, + ParsingConfigurationHeaderError, + NoInstructionsError, + InstructionNotForClaimsError } } diff --git a/src/Ocelot.Library/Middleware/ClaimsParserMiddleware.cs b/src/Ocelot.Library/Middleware/ClaimsParserMiddleware.cs deleted file mode 100644 index 553b6970..00000000 --- a/src/Ocelot.Library/Middleware/ClaimsParserMiddleware.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Ocelot.Library.Middleware -{ - using System.Threading.Tasks; - using Microsoft.AspNetCore.Http; - using Repository; - - public class ClaimsParserMiddleware : OcelotMiddleware - { - private readonly RequestDelegate _next; - - public ClaimsParserMiddleware(RequestDelegate next, IScopedRequestDataRepository scopedRequestDataRepository) - : base(scopedRequestDataRepository) - { - _next = next; - } - - public async Task Invoke(HttpContext context) - { - - await _next.Invoke(context); - } - } -} diff --git a/src/Ocelot.Library/Middleware/HttpRequestHeadersBuilderMiddleware.cs b/src/Ocelot.Library/Middleware/HttpRequestHeadersBuilderMiddleware.cs new file mode 100644 index 00000000..1d45b6c4 --- /dev/null +++ b/src/Ocelot.Library/Middleware/HttpRequestHeadersBuilderMiddleware.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Primitives; +using Ocelot.Library.DownstreamRouteFinder; +using Ocelot.Library.RequestBuilder; + +namespace Ocelot.Library.Middleware +{ + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using Repository; + + public class HttpRequestHeadersBuilderMiddleware : OcelotMiddleware + { + private readonly RequestDelegate _next; + private readonly IAddHeadersToRequest _addHeadersToRequest; + private readonly IScopedRequestDataRepository _scopedRequestDataRepository; + + public HttpRequestHeadersBuilderMiddleware(RequestDelegate next, + IScopedRequestDataRepository scopedRequestDataRepository, + IAddHeadersToRequest addHeadersToRequest) + : base(scopedRequestDataRepository) + { + _next = next; + _addHeadersToRequest = addHeadersToRequest; + _scopedRequestDataRepository = scopedRequestDataRepository; + } + + public async Task Invoke(HttpContext context) + { + var downstreamRoute = _scopedRequestDataRepository.Get("DownstreamRoute"); + + if (downstreamRoute.Data.ReRoute.ConfigurationHeaderExtractorProperties.Any()) + { + _addHeadersToRequest.SetHeadersOnContext(downstreamRoute.Data.ReRoute.ConfigurationHeaderExtractorProperties, context); + } + + await _next.Invoke(context); + } + } +} diff --git a/src/Ocelot.Library/Middleware/HttpRequestHeadersBuilderMiddlewareExtensions.cs b/src/Ocelot.Library/Middleware/HttpRequestHeadersBuilderMiddlewareExtensions.cs new file mode 100644 index 00000000..d657add1 --- /dev/null +++ b/src/Ocelot.Library/Middleware/HttpRequestHeadersBuilderMiddlewareExtensions.cs @@ -0,0 +1,12 @@ +namespace Ocelot.Library.Middleware +{ + using Microsoft.AspNetCore.Builder; + + public static class HttpRequestHeadersBuilderMiddlewareExtensions + { + public static IApplicationBuilder UseHttpRequestHeadersBuilderMiddleware(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} \ No newline at end of file diff --git a/src/Ocelot.Library/Middleware/OcelotMiddlewareExtensions.cs b/src/Ocelot.Library/Middleware/OcelotMiddlewareExtensions.cs index 1fc4bbd8..e1c0ffe6 100644 --- a/src/Ocelot.Library/Middleware/OcelotMiddlewareExtensions.cs +++ b/src/Ocelot.Library/Middleware/OcelotMiddlewareExtensions.cs @@ -12,6 +12,8 @@ builder.UseAuthenticationMiddleware(); + builder.UseHttpRequestHeadersBuilderMiddleware(); + builder.UseDownstreamUrlCreatorMiddleware(); builder.UseHttpRequestBuilderMiddleware(); diff --git a/src/Ocelot.Library/RequestBuilder/AddHeadersToRequest.cs b/src/Ocelot.Library/RequestBuilder/AddHeadersToRequest.cs new file mode 100644 index 00000000..387c69bc --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/AddHeadersToRequest.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Ocelot.Library.Responses; + +namespace Ocelot.Library.RequestBuilder +{ + public class AddHeadersToRequest : IAddHeadersToRequest + { + private readonly IClaimsParser _claimsParser; + + public AddHeadersToRequest(IClaimsParser claimsParser) + { + _claimsParser = claimsParser; + } + + public Response SetHeadersOnContext(List configurationHeaderExtractorProperties, HttpContext context) + { + foreach (var config in configurationHeaderExtractorProperties) + { + var value = _claimsParser.GetValue(context.User.Claims, config.ClaimKey, config.Delimiter, config.Index); + + if (value.IsError) + { + return new ErrorResponse(value.Errors); + } + + var exists = context.Request.Headers.FirstOrDefault(x => x.Key == config.HeaderKey); + + if (!string.IsNullOrEmpty(exists.Key)) + { + context.Request.Headers.Remove(exists); + } + + context.Request.Headers.Add(config.HeaderKey, new StringValues(value.Data)); + } + + return new OkResponse(); + } + } +} \ No newline at end of file diff --git a/src/Ocelot.Library/RequestBuilder/CannotFindClaimError.cs b/src/Ocelot.Library/RequestBuilder/CannotFindClaimError.cs new file mode 100644 index 00000000..0c9e3dac --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/CannotFindClaimError.cs @@ -0,0 +1,12 @@ +using Ocelot.Library.Errors; + +namespace Ocelot.Library.RequestBuilder +{ + public class CannotFindClaimError : Error + { + public CannotFindClaimError(string message) + : base(message, OcelotErrorCode.CannotFindClaimError) + { + } + } +} diff --git a/src/Ocelot.Library/RequestBuilder/ClaimsParser.cs b/src/Ocelot.Library/RequestBuilder/ClaimsParser.cs new file mode 100644 index 00000000..504950e7 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/ClaimsParser.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using Ocelot.Library.Errors; +using Ocelot.Library.Responses; + +namespace Ocelot.Library.RequestBuilder +{ + public class ClaimsParser : IClaimsParser + { + public Response GetValue(IEnumerable claims, string key, string delimiter, int index) + { + var claimResponse = GetValue(claims, key); + + if (claimResponse.IsError) + { + return claimResponse; + } + + if (string.IsNullOrEmpty(delimiter)) + { + return claimResponse; + } + + var splits = claimResponse.Data.Split(delimiter.ToCharArray()); + + if (splits.Length < index || index < 0) + { + return new ErrorResponse(new List + { + new CannotFindClaimError($"Cannot find claim for key: {key}, delimiter: {delimiter}, index: {index}") + }); + } + + var value = splits[index]; + + return new OkResponse(value); + } + + private Response GetValue(IEnumerable claims, string key) + { + var claim = claims.FirstOrDefault(c => c.Type == key); + + if (claim != null) + { + return new OkResponse(claim.Value); + } + + return new ErrorResponse(new List + { + new CannotFindClaimError($"Cannot find claim for key: {key}") + }); + } + } +} diff --git a/src/Ocelot.Library/RequestBuilder/ConfigurationHeaderExtractorProperties.cs b/src/Ocelot.Library/RequestBuilder/ConfigurationHeaderExtractorProperties.cs new file mode 100644 index 00000000..0f95b580 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/ConfigurationHeaderExtractorProperties.cs @@ -0,0 +1,18 @@ +namespace Ocelot.Library.RequestBuilder +{ + public class ConfigurationHeaderExtractorProperties + { + public ConfigurationHeaderExtractorProperties(string headerKey, string claimKey, string delimiter, int index) + { + ClaimKey = claimKey; + Delimiter = delimiter; + Index = index; + HeaderKey = headerKey; + } + + public string HeaderKey { get; private set; } + public string ClaimKey { get; private set; } + public string Delimiter { get; private set; } + public int Index { get; private set; } + } +} diff --git a/src/Ocelot.Library/RequestBuilder/HeaderExtrator.cs b/src/Ocelot.Library/RequestBuilder/HeaderExtrator.cs new file mode 100644 index 00000000..11165f4e --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/HeaderExtrator.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Ocelot.Library.Errors; +using Ocelot.Library.Responses; + +namespace Ocelot.Library.RequestBuilder +{ + public class ConfigurationHeaderExtrator : IConfigurationHeaderExtrator + { + private readonly Regex _claimRegex = new Regex("Claims\\[.*\\]"); + private readonly Regex _indexRegex = new Regex("value\\[.*\\]"); + private const string SplitToken = ">"; + + public Response Extract(string headerKey, string value) + { + try + { + var instructions = value.Split(SplitToken.ToCharArray()); + + if (instructions.Length <= 1) + { + return new ErrorResponse( + new List + { + new NoInstructionsError(SplitToken) + }); + } + + var claimMatch = _claimRegex.IsMatch(instructions[0]); + + if (!claimMatch) + { + return new ErrorResponse( + new List + { + new InstructionNotForClaimsError() + }); + } + + var claimKey = GetIndexValue(instructions[0]); + var index = 0; + var delimiter = string.Empty; + + if (instructions.Length > 2 && _indexRegex.IsMatch(instructions[1])) + { + index = int.Parse(GetIndexValue(instructions[1])); + delimiter = instructions[2].Trim(); + } + + return new OkResponse( + new ConfigurationHeaderExtractorProperties(headerKey, claimKey, delimiter, index)); + } + catch (Exception exception) + { + return new ErrorResponse( + new List + { + new ParsingConfigurationHeaderError(exception) + }); + } + } + + private string GetIndexValue(string instruction) + { + var firstIndexer = instruction.IndexOf("[", StringComparison.Ordinal); + var lastIndexer = instruction.IndexOf("]", StringComparison.Ordinal); + var length = lastIndexer - firstIndexer; + var claimKey = instruction.Substring(firstIndexer + 1, length - 1); + return claimKey; + } + } +} diff --git a/src/Ocelot.Library/RequestBuilder/IAddHeadersToRequest.cs b/src/Ocelot.Library/RequestBuilder/IAddHeadersToRequest.cs new file mode 100644 index 00000000..d35cfb95 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/IAddHeadersToRequest.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Ocelot.Library.Responses; + +namespace Ocelot.Library.RequestBuilder +{ + public interface IAddHeadersToRequest + { + Response SetHeadersOnContext(List configurationHeaderExtractorProperties, + HttpContext context); + } +} diff --git a/src/Ocelot.Library/RequestBuilder/IClaimsParser.cs b/src/Ocelot.Library/RequestBuilder/IClaimsParser.cs new file mode 100644 index 00000000..c3cdd962 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/IClaimsParser.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Security.Claims; +using Ocelot.Library.Responses; + +namespace Ocelot.Library.RequestBuilder +{ + public interface IClaimsParser + { + Response GetValue(IEnumerable claims, string key, string delimiter, int index); + } +} \ No newline at end of file diff --git a/src/Ocelot.Library/RequestBuilder/IConfigurationHeaderExtrator.cs b/src/Ocelot.Library/RequestBuilder/IConfigurationHeaderExtrator.cs new file mode 100644 index 00000000..29c3c153 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/IConfigurationHeaderExtrator.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Ocelot.Library.Responses; + +namespace Ocelot.Library.RequestBuilder +{ + public interface IConfigurationHeaderExtrator + { + Response Extract(string headerKey, string value); + } +} \ No newline at end of file diff --git a/src/Ocelot.Library/RequestBuilder/InstructionNotForClaimsError.cs b/src/Ocelot.Library/RequestBuilder/InstructionNotForClaimsError.cs new file mode 100644 index 00000000..2802cfe5 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/InstructionNotForClaimsError.cs @@ -0,0 +1,12 @@ +using Ocelot.Library.Errors; + +namespace Ocelot.Library.RequestBuilder +{ + public class InstructionNotForClaimsError : Error + { + public InstructionNotForClaimsError() + : base("instructions did not contain claims, at the moment we only support claims extraction", OcelotErrorCode.InstructionNotForClaimsError) + { + } + } +} diff --git a/src/Ocelot.Library/RequestBuilder/NoInstructionsError.cs b/src/Ocelot.Library/RequestBuilder/NoInstructionsError.cs new file mode 100644 index 00000000..9a2bc694 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/NoInstructionsError.cs @@ -0,0 +1,12 @@ +using Ocelot.Library.Errors; + +namespace Ocelot.Library.RequestBuilder +{ + public class NoInstructionsError : Error + { + public NoInstructionsError(string splitToken) + : base($"There we no instructions splitting on {splitToken}", OcelotErrorCode.NoInstructionsError) + { + } + } +} diff --git a/src/Ocelot.Library/RequestBuilder/ParsingConfigurationHeaderError.cs b/src/Ocelot.Library/RequestBuilder/ParsingConfigurationHeaderError.cs new file mode 100644 index 00000000..1b8eaf54 --- /dev/null +++ b/src/Ocelot.Library/RequestBuilder/ParsingConfigurationHeaderError.cs @@ -0,0 +1,13 @@ +using System; +using Ocelot.Library.Errors; + +namespace Ocelot.Library.RequestBuilder +{ + public class ParsingConfigurationHeaderError : Error + { + public ParsingConfigurationHeaderError(Exception exception) + : base($"error parsing configuration eception is {exception.Message}", OcelotErrorCode.ParsingConfigurationHeaderError) + { + } + } +} diff --git a/src/Ocelot.Library/Responder/HttpContextResponder.cs b/src/Ocelot.Library/Responder/HttpContextResponder.cs index 546dd114..d092d36a 100644 --- a/src/Ocelot.Library/Responder/HttpContextResponder.cs +++ b/src/Ocelot.Library/Responder/HttpContextResponder.cs @@ -12,18 +12,14 @@ { public async Task CreateResponse(HttpContext context, HttpResponseMessage response) { - if (response.IsSuccessStatusCode) + context.Response.OnStarting(x => { - context.Response.OnStarting(x => - { - context.Response.StatusCode = (int)response.StatusCode; - return Task.CompletedTask; - }, context); + context.Response.StatusCode = (int)response.StatusCode; + return Task.CompletedTask; + }, context); - await context.Response.WriteAsync(await response.Content.ReadAsStringAsync()); - return context; - } - return context; + await context.Response.WriteAsync(await response.Content.ReadAsStringAsync()); + return context; } public async Task CreateErrorResponse(HttpContext context, int statusCode) diff --git a/test/Ocelot.AcceptanceTests/AuthenticationTests.cs b/test/Ocelot.AcceptanceTests/AuthenticationTests.cs index 1513b190..ee8c6a5c 100644 --- a/test/Ocelot.AcceptanceTests/AuthenticationTests.cs +++ b/test/Ocelot.AcceptanceTests/AuthenticationTests.cs @@ -144,49 +144,6 @@ namespace Ocelot.AcceptanceTests .BDDfy(); } - [Fact] - public void should_return_response_200_and_foward_claim_as_header() - { - - this.Given(x => x.GivenThereIsAnIdentityServerOn("http://localhost:51888", "api", AccessTokenType.Jwt)) - .And(x => x.GivenThereIsAServiceRunningOn("http://localhost:51876", 200, "Hello from Laura")) - .And(x => x.GivenIHaveAToken("http://localhost:51888")) - .And(x => x.GivenThereIsAConfiguration(new YamlConfiguration - { - ReRoutes = new List - { - new YamlReRoute - { - DownstreamTemplate = "http://localhost:51876/", - UpstreamTemplate = "/", - UpstreamHttpMethod = "Get", - AuthenticationOptions = new YamlAuthenticationOptions - { - AdditionalScopes = new List(), - Provider = "IdentityServer", - ProviderRootUrl = "http://localhost:51888", - RequireHttps = false, - ScopeName = "api", - ScopeSecret = "secret" - }, - AddHeadersToRequest = - { - { "CustomerId", "Claims[CustomerId] -> value" }, - { "LocationId", "Claims[LocationId] -> value"}, - { "UserId", "Claims[Subject] -> delimiter(|) -> value[0]" }, - { "UserId", "Claims[Subject] -> delimiter(|) -> value[1]" } - } - } - } - })) - .And(x => x.GivenTheApiGatewayIsRunning()) - .And(x => x.GivenIHaveAddedATokenToMyRequest()) - .When(x => x.WhenIGetUrlOnTheApiGateway("/")) - .Then(x => x.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) - .And(x => x.ThenTheResponseBodyShouldBe("Hello from Laura")) - .BDDfy(); - } - [Fact] public void should_return_201_using_identity_server_access_token() { diff --git a/test/Ocelot.AcceptanceTests/ClaimsToHeadersForwardingTests.cs b/test/Ocelot.AcceptanceTests/ClaimsToHeadersForwardingTests.cs new file mode 100644 index 00000000..33bbe648 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/ClaimsToHeadersForwardingTests.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using IdentityServer4.Models; +using IdentityServer4.Services.InMemory; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Ocelot.Library.Configuration.Yaml; +using Shouldly; +using TestStack.BDDfy; +using Xunit; +using YamlDotNet.Serialization; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] +namespace Ocelot.AcceptanceTests +{ + public class ClaimsToHeadersForwardingTests : IDisposable + { + private TestServer _ocelotServer; + private HttpClient _ocelotClient; + private HttpResponseMessage _response; + private readonly string _configurationPath; + private IWebHost _servicebuilder; + + // Sadly we need to change this when we update the netcoreapp version to make the test update the config correctly + private double _netCoreAppVersion = 1.4; + private BearerToken _token; + private IWebHost _identityServerBuilder; + + public ClaimsToHeadersForwardingTests() + { + _configurationPath = $"./bin/Debug/netcoreapp{_netCoreAppVersion}/configuration.yaml"; + } + + [Fact] + public void should_return_response_200_and_foward_claim_as_header() + { + var user = new InMemoryUser + { + Username = "test", + Password = "test", + Enabled = true, + Subject = "registered|1231231", + Claims = new List + { + new Claim("CustomerId", "123"), + new Claim("LocationId", "1") + } + }; + + this.Given(x => x.GivenThereIsAnIdentityServerOn("http://localhost:52888", "api", AccessTokenType.Jwt, user)) + .And(x => x.GivenThereIsAServiceRunningOn("http://localhost:52876", 200)) + .And(x => x.GivenIHaveAToken("http://localhost:52888")) + .And(x => x.GivenThereIsAConfiguration(new YamlConfiguration + { + ReRoutes = new List + { + new YamlReRoute + { + DownstreamTemplate = "http://localhost:52876/", + UpstreamTemplate = "/", + UpstreamHttpMethod = "Get", + AuthenticationOptions = new YamlAuthenticationOptions + { + AdditionalScopes = new List + { + "openid", "offline_access" + }, + Provider = "IdentityServer", + ProviderRootUrl = "http://localhost:52888", + RequireHttps = false, + ScopeName = "api", + ScopeSecret = "secret", + }, + AddHeadersToRequest = + { + {"CustomerId", "Claims[CustomerId] > value"}, + {"LocationId", "Claims[LocationId] > value"}, + {"UserType", "Claims[sub] > value[0] > |"}, + {"UserId", "Claims[sub] > value[1] > |"} + } + } + } + })) + .And(x => x.GivenTheApiGatewayIsRunning()) + .And(x => x.GivenIHaveAddedATokenToMyRequest()) + .When(x => x.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => x.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => x.ThenTheResponseBodyShouldBe("CustomerId: 123 LocationId: 1 UserType: registered UserId: 1231231")) + .BDDfy(); + } + + private void WhenIGetUrlOnTheApiGateway(string url) + { + _response = _ocelotClient.GetAsync(url).Result; + } + + private void ThenTheResponseBodyShouldBe(string expectedBody) + { + _response.Content.ReadAsStringAsync().Result.ShouldBe(expectedBody); + } + + /// + /// This is annoying cos it should be in the constructor but we need to set up the yaml file before calling startup so its a step. + /// + private void GivenTheApiGatewayIsRunning() + { + _ocelotServer = new TestServer(new WebHostBuilder() + .UseStartup()); + + _ocelotClient = _ocelotServer.CreateClient(); + } + + private void GivenThereIsAConfiguration(YamlConfiguration yamlConfiguration) + { + var serializer = new Serializer(); + + if (File.Exists(_configurationPath)) + { + File.Delete(_configurationPath); + } + + using (TextWriter writer = File.CreateText(_configurationPath)) + { + serializer.Serialize(writer, yamlConfiguration); + } + } + + private void GivenThereIsAServiceRunningOn(string url, int statusCode) + { + _servicebuilder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .Configure(app => + { + app.Run(async context => + { + var customerId = context.Request.Headers.First(x => x.Key == "CustomerId").Value.First(); + var locationId = context.Request.Headers.First(x => x.Key == "LocationId").Value.First(); + var userType = context.Request.Headers.First(x => x.Key == "UserType").Value.First(); + var userId = context.Request.Headers.First(x => x.Key == "UserId").Value.First(); + + var responseBody = $"CustomerId: {customerId} LocationId: {locationId} UserType: {userType} UserId: {userId}"; + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(responseBody); + }); + }) + .Build(); + + _servicebuilder.Start(); + } + + private void GivenThereIsAnIdentityServerOn(string url, string scopeName, AccessTokenType tokenType, InMemoryUser user) + { + _identityServerBuilder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .ConfigureServices(services => + { + services.AddLogging(); + services.AddDeveloperIdentityServer() + .AddInMemoryScopes(new List + { + new Scope + { + Name = scopeName, + Description = "My API", + Enabled = true, + AllowUnrestrictedIntrospection = true, + ScopeSecrets = new List() + { + new Secret + { + Value = "secret".Sha256() + } + }, + IncludeAllClaimsForUser = true + }, + + StandardScopes.OpenId, + StandardScopes.OfflineAccess + }) + .AddInMemoryClients(new List + { + new Client + { + ClientId = "client", + AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, + ClientSecrets = new List {new Secret("secret".Sha256())}, + AllowedScopes = new List { scopeName, "openid", "offline_access" }, + AccessTokenType = tokenType, + Enabled = true, + RequireClientSecret = false + } + }) + .AddInMemoryUsers(new List + { + user + }); + }) + .Configure(app => + { + app.UseIdentityServer(); + }) + .Build(); + + _identityServerBuilder.Start(); + + VerifyIdentiryServerStarted(url); + + } + + private void VerifyIdentiryServerStarted(string url) + { + using (var httpClient = new HttpClient()) + { + var response = httpClient.GetAsync($"{url}/.well-known/openid-configuration").Result; + response.EnsureSuccessStatusCode(); + } + } + + private void GivenIHaveAToken(string url) + { + var tokenUrl = $"{url}/connect/token"; + var formData = new List> + { + new KeyValuePair("client_id", "client"), + new KeyValuePair("client_secret", "secret"), + new KeyValuePair("scope", "api"), + new KeyValuePair("username", "test"), + new KeyValuePair("password", "test"), + new KeyValuePair("grant_type", "password") + }; + var content = new FormUrlEncodedContent(formData); + + using (var httpClient = new HttpClient()) + { + var response = httpClient.PostAsync(tokenUrl, content).Result; + response.EnsureSuccessStatusCode(); + var responseContent = response.Content.ReadAsStringAsync().Result; + _token = JsonConvert.DeserializeObject(responseContent); + } + } + + private void GivenIHaveAddedATokenToMyRequest() + { + _ocelotClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken); + } + + private void ThenTheStatusCodeShouldBe(HttpStatusCode expectedHttpStatusCode) + { + _response.StatusCode.ShouldBe(expectedHttpStatusCode); + } + + public void Dispose() + { + _servicebuilder?.Dispose(); + _ocelotClient?.Dispose(); + _ocelotServer?.Dispose(); + _identityServerBuilder?.Dispose(); + } + + // ReSharper disable once ClassNeverInstantiated.Local + class BearerToken + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + [JsonProperty("token_type")] + public string TokenType { get; set; } + } + } +} diff --git a/test/Ocelot.AcceptanceTests/ReturnsErrorTests.cs b/test/Ocelot.AcceptanceTests/ReturnsErrorTests.cs new file mode 100644 index 00000000..dafd69f1 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/ReturnsErrorTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Ocelot.Library.Configuration.Yaml; +using Shouldly; +using TestStack.BDDfy; +using Xunit; +using YamlDotNet.Serialization; + +namespace Ocelot.AcceptanceTests +{ + public class ReturnsErrorTests : IDisposable + { + private TestServer _ocelotServer; + private HttpClient _ocelotClient; + private HttpResponseMessage _response; + private readonly string _configurationPath; + private IWebHost _servicebuilder; + + // Sadly we need to change this when we update the netcoreapp version to make the test update the config correctly + private double _netCoreAppVersion = 1.4; + + public ReturnsErrorTests() + { + _configurationPath = $"./bin/Debug/netcoreapp{_netCoreAppVersion}/configuration.yaml"; + } + + [Fact] + public void should_return_response_200_and_foward_claim_as_header() + { + this.Given(x => x.GivenThereIsAServiceRunningOn("http://localhost:53876")) + .And(x => x.GivenThereIsAConfiguration(new YamlConfiguration + { + ReRoutes = new List + { + new YamlReRoute + { + DownstreamTemplate = "http://localhost:53876/", + UpstreamTemplate = "/", + UpstreamHttpMethod = "Get", + } + } + })) + .And(x => x.GivenTheApiGatewayIsRunning()) + .When(x => x.WhenIGetUrlOnTheApiGateway("/")) + .Then(x => x.ThenTheStatusCodeShouldBe(HttpStatusCode.InternalServerError)) + .BDDfy(); + } + + private void WhenIGetUrlOnTheApiGateway(string url) + { + _response = _ocelotClient.GetAsync(url).Result; + } + + private void ThenTheResponseBodyShouldBe(string expectedBody) + { + _response.Content.ReadAsStringAsync().Result.ShouldBe(expectedBody); + } + + /// + /// This is annoying cos it should be in the constructor but we need to set up the yaml file before calling startup so its a step. + /// + private void GivenTheApiGatewayIsRunning() + { + _ocelotServer = new TestServer(new WebHostBuilder() + .UseStartup()); + + _ocelotClient = _ocelotServer.CreateClient(); + } + + private void GivenThereIsAConfiguration(YamlConfiguration yamlConfiguration) + { + var serializer = new Serializer(); + + if (File.Exists(_configurationPath)) + { + File.Delete(_configurationPath); + } + + using (TextWriter writer = File.CreateText(_configurationPath)) + { + serializer.Serialize(writer, yamlConfiguration); + } + } + + private void GivenThereIsAServiceRunningOn(string url) + { + _servicebuilder = new WebHostBuilder() + .UseUrls(url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(url) + .Configure(app => + { + app.Run(context => + { + throw new Exception("BLAMMMM"); + }); + }) + .Build(); + + _servicebuilder.Start(); + } + + private void ThenTheStatusCodeShouldBe(HttpStatusCode expectedHttpStatusCode) + { + _response.StatusCode.ShouldBe(expectedHttpStatusCode); + } + + public void Dispose() + { + _servicebuilder?.Dispose(); + _ocelotClient?.Dispose(); + _ocelotServer?.Dispose(); + } + } +} diff --git a/test/Ocelot.AcceptanceTests/project.json b/test/Ocelot.AcceptanceTests/project.json index 722e58e4..e36303e4 100644 --- a/test/Ocelot.AcceptanceTests/project.json +++ b/test/Ocelot.AcceptanceTests/project.json @@ -3,9 +3,9 @@ "buildOptions": { "copyToOutput": { - "include": [ - "configuration.yaml" - ] + "include": [ + "configuration.yaml" + ] } }, diff --git a/test/Ocelot.UnitTests/Configuration/OcelotConfigurationTests.cs b/test/Ocelot.UnitTests/Configuration/OcelotConfigurationTests.cs index f1601a41..7c37e4a9 100644 --- a/test/Ocelot.UnitTests/Configuration/OcelotConfigurationTests.cs +++ b/test/Ocelot.UnitTests/Configuration/OcelotConfigurationTests.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; +using Ocelot.Library.RequestBuilder; using Shouldly; using TestStack.BDDfy; using Xunit; @@ -18,9 +20,13 @@ namespace Ocelot.UnitTests.Configuration private readonly Mock _validator; private OcelotConfiguration _config; private YamlConfiguration _yamlConfiguration; + private readonly Mock _configExtractor; + private readonly Mock> _logger; public OcelotConfigurationTests() { + _logger = new Mock>(); + _configExtractor = new Mock(); _validator = new Mock(); _yamlConfig = new Mock>(); } @@ -54,6 +60,114 @@ namespace Ocelot.UnitTests.Configuration .BDDfy(); } + [Fact] + public void should_create_with_headers_to_extract() + { + var expected = new List + { + new ReRouteBuilder() + .WithDownstreamTemplate("/products/{productId}") + .WithUpstreamTemplate("/api/products/{productId}") + .WithUpstreamHttpMethod("Get") + .WithUpstreamTemplatePattern("/api/products/.*$") + .WithAuthenticationProvider("IdentityServer") + .WithAuthenticationProviderUrl("http://localhost:51888") + .WithRequireHttps(false) + .WithScopeSecret("secret") + .WithAuthenticationProviderScopeName("api") + .WithConfigurationHeaderExtractorProperties(new List + { + new ConfigurationHeaderExtractorProperties("CustomerId", "CustomerId", "", 0), + }) + .Build() + }; + + this.Given(x => x.GivenTheYamlConfigIs(new YamlConfiguration + { + ReRoutes = new List + { + new YamlReRoute + { + UpstreamTemplate = "/api/products/{productId}", + DownstreamTemplate = "/products/{productId}", + UpstreamHttpMethod = "Get", + AuthenticationOptions = new YamlAuthenticationOptions + { + AdditionalScopes = new List(), + Provider = "IdentityServer", + ProviderRootUrl = "http://localhost:51888", + RequireHttps = false, + ScopeName = "api", + ScopeSecret = "secret" + }, + AddHeadersToRequest = + { + {"CustomerId", "Claims[CustomerId] > value"}, + } + } + } + })) + .And(x => x.GivenTheYamlConfigIsValid()) + .And(x => x.GivenTheConfigHeaderExtractorReturns(new ConfigurationHeaderExtractorProperties("CustomerId", "CustomerId", "", 0))) + .When(x => x.WhenIInstanciateTheOcelotConfig()) + .Then(x => x.ThenTheReRoutesAre(expected)) + .And(x => x.ThenTheAuthenticationOptionsAre(expected)) + .BDDfy(); + } + + private void GivenTheConfigHeaderExtractorReturns(ConfigurationHeaderExtractorProperties expected) + { + _configExtractor + .Setup(x => x.Extract(It.IsAny(), It.IsAny())) + .Returns(new OkResponse(expected)); + } + + [Fact] + public void should_create_with_authentication_properties() + { + var expected = new List + { + new ReRouteBuilder() + .WithDownstreamTemplate("/products/{productId}") + .WithUpstreamTemplate("/api/products/{productId}") + .WithUpstreamHttpMethod("Get") + .WithUpstreamTemplatePattern("/api/products/.*$") + .WithAuthenticationProvider("IdentityServer") + .WithAuthenticationProviderUrl("http://localhost:51888") + .WithRequireHttps(false) + .WithScopeSecret("secret") + .WithAuthenticationProviderScopeName("api") + .Build() + }; + + this.Given(x => x.GivenTheYamlConfigIs(new YamlConfiguration + { + ReRoutes = new List + { + new YamlReRoute + { + UpstreamTemplate = "/api/products/{productId}", + DownstreamTemplate = "/products/{productId}", + UpstreamHttpMethod = "Get", + AuthenticationOptions = new YamlAuthenticationOptions + { + AdditionalScopes = new List(), + Provider = "IdentityServer", + ProviderRootUrl = "http://localhost:51888", + RequireHttps = false, + ScopeName = "api", + ScopeSecret = "secret" + } + } + } + })) + .And(x => x.GivenTheYamlConfigIsValid()) + .When(x => x.WhenIInstanciateTheOcelotConfig()) + .Then(x => x.ThenTheReRoutesAre(expected)) + .And(x => x.ThenTheAuthenticationOptionsAre(expected)) + .BDDfy(); + } + [Fact] public void should_create_template_pattern_that_matches_more_than_one_placeholder() { @@ -158,7 +272,8 @@ namespace Ocelot.UnitTests.Configuration private void WhenIInstanciateTheOcelotConfig() { - _config = new OcelotConfiguration(_yamlConfig.Object, _validator.Object); + _config = new OcelotConfiguration(_yamlConfig.Object, _validator.Object, + _configExtractor.Object, _logger.Object); } private void ThenTheReRoutesAre(List expectedReRoutes) @@ -174,5 +289,22 @@ namespace Ocelot.UnitTests.Configuration result.UpstreamTemplatePattern.ShouldBe(expected.UpstreamTemplatePattern); } } + + private void ThenTheAuthenticationOptionsAre(List expectedReRoutes) + { + for (int i = 0; i < _config.ReRoutes.Count; i++) + { + var result = _config.ReRoutes[i].AuthenticationOptions; + var expected = expectedReRoutes[i].AuthenticationOptions; + + result.AdditionalScopes.ShouldBe(expected.AdditionalScopes); + result.Provider.ShouldBe(expected.Provider); + result.ProviderRootUrl.ShouldBe(expected.ProviderRootUrl); + result.RequireHttps.ShouldBe(expected.RequireHttps); + result.ScopeName.ShouldBe(expected.ScopeName); + result.ScopeSecret.ShouldBe(expected.ScopeSecret); + + } + } } } diff --git a/test/Ocelot.UnitTests/Middleware/HttpRequestHeadersBuilderMiddlewareTests.cs b/test/Ocelot.UnitTests/Middleware/HttpRequestHeadersBuilderMiddlewareTests.cs new file mode 100644 index 00000000..6dc7f77e --- /dev/null +++ b/test/Ocelot.UnitTests/Middleware/HttpRequestHeadersBuilderMiddlewareTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Ocelot.Library.Builder; +using Ocelot.Library.DownstreamRouteFinder; +using Ocelot.Library.Middleware; +using Ocelot.Library.Repository; +using Ocelot.Library.RequestBuilder; +using Ocelot.Library.Responses; +using Ocelot.Library.UrlMatcher; +using TestStack.BDDfy; +using Xunit; + +namespace Ocelot.UnitTests.Middleware +{ + public class HttpRequestHeadersBuilderMiddlewareTests : IDisposable + { + private readonly Mock _scopedRepository; + private readonly Mock _addHeaders; + private readonly string _url; + private readonly TestServer _server; + private readonly HttpClient _client; + private Response _downstreamRoute; + private HttpResponseMessage _result; + + public HttpRequestHeadersBuilderMiddlewareTests() + { + _url = "http://localhost:51879"; + _scopedRepository = new Mock(); + _addHeaders = new Mock(); + var builder = new WebHostBuilder() + .ConfigureServices(x => + { + x.AddSingleton(_addHeaders.Object); + x.AddSingleton(_scopedRepository.Object); + }) + .UseUrls(_url) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseUrls(_url) + .Configure(app => + { + app.UseHttpRequestHeadersBuilderMiddleware(); + }); + + _server = new TestServer(builder); + _client = _server.CreateClient(); + } + + [Fact] + public void happy_path() + { + var downstreamRoute = new DownstreamRoute(new List(), + new ReRouteBuilder() + .WithDownstreamTemplate("any old string") + .WithConfigurationHeaderExtractorProperties(new List + { + new ConfigurationHeaderExtractorProperties("UserId", "Subject", "", 0) + }) + .Build()); + + this.Given(x => x.GivenTheDownStreamRouteIs(downstreamRoute)) + .And(x => x.GivenTheAddHeadersToRequestReturns("123")) + .When(x => x.WhenICallTheMiddleware()) + .Then(x => x.ThenTheAddHeadersToRequestIsCalledCorrectly()) + .BDDfy(); + } + + private void GivenTheAddHeadersToRequestReturns(string claimValue) + { + _addHeaders + .Setup(x => x.SetHeadersOnContext(It.IsAny>(), + It.IsAny())) + .Returns(new OkResponse()); + } + + private void ThenTheAddHeadersToRequestIsCalledCorrectly() + { + _addHeaders + .Verify(x => x.SetHeadersOnContext(It.IsAny>(), + It.IsAny()), Times.Once); + } + + private void WhenICallTheMiddleware() + { + _result = _client.GetAsync(_url).Result; + } + + private void GivenTheDownStreamRouteIs(DownstreamRoute downstreamRoute) + { + _downstreamRoute = new OkResponse(downstreamRoute); + _scopedRepository + .Setup(x => x.Get(It.IsAny())) + .Returns(_downstreamRoute); + } + + public void Dispose() + { + _client.Dispose(); + _server.Dispose(); + } + } +} diff --git a/test/Ocelot.UnitTests/RequestBuilder/AddHeadersToRequestTests.cs b/test/Ocelot.UnitTests/RequestBuilder/AddHeadersToRequestTests.cs new file mode 100644 index 00000000..28300c17 --- /dev/null +++ b/test/Ocelot.UnitTests/RequestBuilder/AddHeadersToRequestTests.cs @@ -0,0 +1,152 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Moq; +using Ocelot.Library.Errors; +using Ocelot.Library.RequestBuilder; +using Ocelot.Library.Responses; +using Shouldly; +using TestStack.BDDfy; +using Xunit; + +namespace Ocelot.UnitTests.RequestBuilder +{ + public class AddHeadersToRequestTests + { + private readonly AddHeadersToRequest _addHeadersToRequest; + private readonly Mock _parser; + private List _configuration; + private HttpContext _context; + private Response _result; + private Response _claimValue; + + public AddHeadersToRequestTests() + { + _parser = new Mock(); + _addHeadersToRequest = new AddHeadersToRequest(_parser.Object); + } + + [Fact] + public void should_add_headers_to_context() + { + var context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new List + { + new Claim("test", "data") + })) + }; + + this.Given( + x => x.GivenConfigurationHeaderExtractorProperties(new List + { + new ConfigurationHeaderExtractorProperties("header-key", "", "", 0) + })) + .Given(x => x.GivenHttpContext(context)) + .And(x => x.GivenTheClaimParserReturns(new OkResponse("value"))) + .When(x => x.WhenIAddHeadersToTheRequest()) + .Then(x => x.ThenTheResultIsSuccess()) + .And(x => x.ThenTheHeaderIsAdded()) + .BDDfy(); + } + + [Fact] + public void if_header_exists_should_replace_it() + { + var context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new List + { + new Claim("test", "data") + })), + }; + + context.Request.Headers.Add("header-key", new StringValues("initial")); + + this.Given( + x => x.GivenConfigurationHeaderExtractorProperties(new List + { + new ConfigurationHeaderExtractorProperties("header-key", "", "", 0) + })) + .Given(x => x.GivenHttpContext(context)) + .And(x => x.GivenTheClaimParserReturns(new OkResponse("value"))) + .When(x => x.WhenIAddHeadersToTheRequest()) + .Then(x => x.ThenTheResultIsSuccess()) + .And(x => x.ThenTheHeaderIsAdded()) + .BDDfy(); + } + + [Fact] + public void should_return_error() + { + this.Given( + x => x.GivenConfigurationHeaderExtractorProperties(new List + { + new ConfigurationHeaderExtractorProperties("", "", "", 0) + })) + .Given(x => x.GivenHttpContext(new DefaultHttpContext())) + .And(x => x.GivenTheClaimParserReturns(new ErrorResponse(new List + { + new AnyError() + }))) + .When(x => x.WhenIAddHeadersToTheRequest()) + .Then(x => x.ThenTheResultIsError()) + .BDDfy(); + } + + private void ThenTheHeaderIsAdded() + { + var header = _context.Request.Headers.First(x => x.Key == "header-key"); + header.Value.First().ShouldBe(_claimValue.Data); + } + + private void GivenConfigurationHeaderExtractorProperties(List configuration) + { + _configuration = configuration; + } + + private void GivenHttpContext(HttpContext context) + { + _context = context; + } + + private void GivenTheClaimParserReturns(Response claimValue) + { + _claimValue = claimValue; + _parser + .Setup( + x => + x.GetValue(It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(_claimValue); + } + + private void WhenIAddHeadersToTheRequest() + { + _result = _addHeadersToRequest.SetHeadersOnContext(_configuration, _context); + } + + private void ThenTheResultIsSuccess() + { + _result.IsError.ShouldBe(false); + } + + private void ThenTheResultIsError() + { + + _result.IsError.ShouldBe(true); + } + + class AnyError : Error + { + public AnyError() + : base("blahh", OcelotErrorCode.UnknownError) + { + } + } + } +} diff --git a/test/Ocelot.UnitTests/RequestBuilder/ClaimParserTests.cs b/test/Ocelot.UnitTests/RequestBuilder/ClaimParserTests.cs new file mode 100644 index 00000000..5daa7d6d --- /dev/null +++ b/test/Ocelot.UnitTests/RequestBuilder/ClaimParserTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Security.Claims; +using Ocelot.Library.Errors; +using Ocelot.Library.RequestBuilder; +using Ocelot.Library.Responses; +using Shouldly; +using TestStack.BDDfy; +using Xunit; + +namespace Ocelot.UnitTests.RequestBuilder +{ + public class ClaimParserTests + { + private readonly IClaimsParser _claimsParser; + private readonly List _claims; + private string _key; + private Response _result; + private string _delimiter; + private int _index; + + public ClaimParserTests() + { + _claims = new List(); + _claimsParser = new Library.RequestBuilder.ClaimsParser(); + } + + [Fact] + public void can_parse_claims_dictionary_access_string_returning_value_to_function() + { + this.Given(x => x.GivenAClaimOf(new Claim("CustomerId", "1234"))) + .And(x => x.GivenTheKeyIs("CustomerId")) + .When(x => x.WhenICallTheParser()) + .Then(x => x.ThenTheResultIs(new OkResponse("1234"))) + .BDDfy(); + } + + [Fact] + public void should_return_error_response_when_cannot_find_requested_claim() + { + this.Given(x => x.GivenAClaimOf(new Claim("BallsId", "1234"))) + .And(x => x.GivenTheKeyIs("CustomerId")) + .When(x => x.WhenICallTheParser()) + .Then(x => x.ThenTheResultIs(new ErrorResponse(new List + { + new CannotFindClaimError($"Cannot find claim for key: {_key}") + }))) + .BDDfy(); + } + + [Fact] + public void can_parse_claims_dictionary_access_string_using_delimiter_and_retuning_at_correct_index() + { + this.Given(x => x.GivenAClaimOf(new Claim("Subject", "registered|4321"))) + .And(x => x.GivenTheDelimiterIs("|")) + .And(x => x.GivenTheIndexIs(1)) + .And(x => x.GivenTheKeyIs("Subject")) + .When(x => x.WhenICallTheParser()) + .Then(x => x.ThenTheResultIs(new OkResponse("4321"))) + .BDDfy(); + } + + [Fact] + public void should_return_error_response_if_index_too_large() + { + this.Given(x => x.GivenAClaimOf(new Claim("Subject", "registered|4321"))) + .And(x => x.GivenTheDelimiterIs("|")) + .And(x => x.GivenTheIndexIs(24)) + .And(x => x.GivenTheKeyIs("Subject")) + .When(x => x.WhenICallTheParser()) + .Then(x => x.ThenTheResultIs(new ErrorResponse(new List + { + new CannotFindClaimError($"Cannot find claim for key: {_key}, delimiter: {_delimiter}, index: {_index}") + }))) + .BDDfy(); + } + + [Fact] + public void should_return_error_response_if_index_too_small() + { + this.Given(x => x.GivenAClaimOf(new Claim("Subject", "registered|4321"))) + .And(x => x.GivenTheDelimiterIs("|")) + .And(x => x.GivenTheIndexIs(-1)) + .And(x => x.GivenTheKeyIs("Subject")) + .When(x => x.WhenICallTheParser()) + .Then(x => x.ThenTheResultIs(new ErrorResponse(new List + { + new CannotFindClaimError($"Cannot find claim for key: {_key}, delimiter: {_delimiter}, index: {_index}") + }))) + .BDDfy(); + } + + private void GivenTheIndexIs(int index) + { + _index = index; + } + + private void GivenTheDelimiterIs(string delimiter) + { + _delimiter = delimiter; + } + + private void GivenAClaimOf(Claim claim) + { + _claims.Add(claim); + } + + private void GivenTheKeyIs(string key) + { + _key = key; + } + + private void WhenICallTheParser() + { + _result = _claimsParser.GetValue(_claims, _key, _delimiter, _index); + } + + private void ThenTheResultIs(Response expected) + { + _result.Data.ShouldBe(expected.Data); + _result.IsError.ShouldBe(expected.IsError); + } + } +} diff --git a/test/Ocelot.UnitTests/RequestBuilder/ConfigurationHeadersExtractorTests.cs b/test/Ocelot.UnitTests/RequestBuilder/ConfigurationHeadersExtractorTests.cs new file mode 100644 index 00000000..238d9f1a --- /dev/null +++ b/test/Ocelot.UnitTests/RequestBuilder/ConfigurationHeadersExtractorTests.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Linq; +using Ocelot.Library.Errors; +using Ocelot.Library.RequestBuilder; +using Ocelot.Library.Responses; +using Shouldly; +using TestStack.BDDfy; +using Xunit; + +namespace Ocelot.UnitTests.RequestBuilder +{ + public class ConfigurationHeadersExtractorTests + { + private Dictionary _dictionary; + private readonly IConfigurationHeaderExtrator _configurationHeaderExtrator; + private Response _result; + + public ConfigurationHeadersExtractorTests() + { + _configurationHeaderExtrator = new ConfigurationHeaderExtrator(); + } + + [Fact] + public void returns_no_instructions_error() + { + this.Given(x => x.GivenTheDictionaryIs(new Dictionary() + { + {"CustomerId", ""}, + })) + .When(x => x.WhenICallTheExtractor()) + .Then( + x => + x.ThenAnErrorIsReturned(new ErrorResponse( + new List + { + new NoInstructionsError(">") + }))) + .BDDfy(); + } + + [Fact] + public void returns_no_instructions_not_for_claims_error() + { + this.Given(x => x.GivenTheDictionaryIs(new Dictionary() + { + {"CustomerId", "Cheese[CustomerId] > value"}, + })) + .When(x => x.WhenICallTheExtractor()) + .Then( + x => + x.ThenAnErrorIsReturned(new ErrorResponse( + new List + { + new InstructionNotForClaimsError() + }))) + .BDDfy(); + } + + [Fact] + public void can_parse_entry_to_work_out_properties_with_key() + { + this.Given(x => x.GivenTheDictionaryIs(new Dictionary() + { + {"CustomerId", "Claims[CustomerId] > value"}, + })) + .When(x => x.WhenICallTheExtractor()) + .Then( + x => + x.ThenTheClaimParserPropertiesAreReturned( + new OkResponse( + new ConfigurationHeaderExtractorProperties("CustomerId", "CustomerId", "", 0)))) + .BDDfy(); + } + + [Fact] + public void can_parse_entry_to_work_out_properties_with_key_delimiter_and_index() + { + this.Given(x => x.GivenTheDictionaryIs(new Dictionary() + { + {"UserId", "Claims[Subject] > value[0] > |"}, + })) + .When(x => x.WhenICallTheExtractor()) + .Then( + x => + x.ThenTheClaimParserPropertiesAreReturned( + new OkResponse( + new ConfigurationHeaderExtractorProperties("UserId", "Subject", "|", 0)))) + .BDDfy(); + } + + private void ThenAnErrorIsReturned(Response expected) + { + _result.IsError.ShouldBe(expected.IsError); + _result.Errors[0].ShouldBeOfType(expected.Errors[0].GetType()); + } + + private void ThenTheClaimParserPropertiesAreReturned(Response expected) + { + _result.Data.ClaimKey.ShouldBe(expected.Data.ClaimKey); + _result.Data.Delimiter.ShouldBe(expected.Data.Delimiter); + _result.Data.Index.ShouldBe(expected.Data.Index); + _result.IsError.ShouldBe(expected.IsError); + } + + private void WhenICallTheExtractor() + { + var first = _dictionary.First(); + _result = _configurationHeaderExtrator.Extract(first.Key, first.Value); + } + + private void GivenTheDictionaryIs(Dictionary dictionary) + { + _dictionary = dictionary; + } + } +}