From f28cf2bbc8e97a05be575a61af08f01edf0ac9c9 Mon Sep 17 00:00:00 2001 From: Alessio Franceschelli Date: Fri, 29 Mar 2019 19:00:46 +0000 Subject: [PATCH] HeaderPropagation: propagate incoming request headers to outgoing HTTP requests (#7921) * Ported HeaderPropagation from aspnet/Extensions * Introduced Middleware * Refactored middleware logic * Refactored builder extensions * Copyright notice * Test for friendly exception on Builder * Fixed header name selection when no output name specified * Set comparer for the dictionary of headers * Refactored configuration as Dictionary * Renamed state objects * renamed OutboundHeaderName in configuration * Changed DefaultValuesGenerator to ValueFactory * Missing docs * Removed AlwaysAdd and added tests for null entry in configuration * Improved docs * Update src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationExtensions.cs Co-Authored-By: alefranz * Moved dependency injection extensions * DI: reused ServiceCollection extension in the HttpClientBuilder one * Moved service registration * Update src/Middleware/HeaderPropagation/src/HeaderPropagationEntry.cs Co-Authored-By: alefranz * more docs * Improved docs * Update src/Middleware/HeaderPropagation/src/HeaderPropagationValues.cs Co-Authored-By: alefranz * Fixed build * Update eng/SharedFramework.Local.props Co-Authored-By: alefranz * Updated tests for null config * Reversed condition on HeaderPropagationMessageHandler as suggested * Added docs for HeaderPropagationMessageHandler * Changed proj to ship package to NuGet --- eng/ProjectReferences.props | 1 + ...PropagationApplicationBuilderExtensions.cs | 38 +++ ...rPropagationHttpClientBuilderExtensions.cs | 30 +++ ...rPropagationServiceCollectionExtensions.cs | 55 +++++ .../src/HeaderPropagationEntry.cs | 56 +++++ .../src/HeaderPropagationMessageHandler.cs | 67 ++++++ .../src/HeaderPropagationMiddleware.cs | 67 ++++++ .../src/HeaderPropagationOptions.cs | 19 ++ .../src/HeaderPropagationValues.cs | 29 +++ ...rosoft.AspNetCore.HeaderPropagation.csproj | 22 ++ .../test/HeaderPropagationIntegrationTest.cs | 109 +++++++++ .../HeaderPropagationMessageHandlerTest.cs | 180 +++++++++++++++ .../test/HeaderPropagationMiddlewareTest.cs | 217 ++++++++++++++++++ ....AspNetCore.HeaderPropagation.Tests.csproj | 13 ++ src/Middleware/Middleware.sln | 35 +++ 15 files changed, 938 insertions(+) create mode 100644 src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationApplicationBuilderExtensions.cs create mode 100644 src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationHttpClientBuilderExtensions.cs create mode 100644 src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationServiceCollectionExtensions.cs create mode 100644 src/Middleware/HeaderPropagation/src/HeaderPropagationEntry.cs create mode 100644 src/Middleware/HeaderPropagation/src/HeaderPropagationMessageHandler.cs create mode 100644 src/Middleware/HeaderPropagation/src/HeaderPropagationMiddleware.cs create mode 100644 src/Middleware/HeaderPropagation/src/HeaderPropagationOptions.cs create mode 100644 src/Middleware/HeaderPropagation/src/HeaderPropagationValues.cs create mode 100644 src/Middleware/HeaderPropagation/src/Microsoft.AspNetCore.HeaderPropagation.csproj create mode 100644 src/Middleware/HeaderPropagation/test/HeaderPropagationIntegrationTest.cs create mode 100644 src/Middleware/HeaderPropagation/test/HeaderPropagationMessageHandlerTest.cs create mode 100644 src/Middleware/HeaderPropagation/test/HeaderPropagationMiddlewareTest.cs create mode 100644 src/Middleware/HeaderPropagation/test/Microsoft.AspNetCore.HeaderPropagation.Tests.csproj diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 56e3e873c2b1..ba4bf03987ad 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -73,6 +73,7 @@ + diff --git a/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationApplicationBuilderExtensions.cs b/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationApplicationBuilderExtensions.cs new file mode 100644 index 000000000000..7297b1de7ec9 --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationApplicationBuilderExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net.Http; +using Microsoft.AspNetCore.HeaderPropagation; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Builder +{ + public static class HeaderPropagationApplicationBuilderExtensions + { + private static readonly string _unableToFindServices = string.Format( + "Unable to find the required services. Please add all the required services by calling '{0}.{1}' inside the call to 'ConfigureServices(...)' in the application startup code.", + nameof(IServiceCollection), + nameof(HeaderPropagationServiceCollectionExtensions.AddHeaderPropagation)); + + /// + /// Adds a middleware that collect headers to be propagated to a . + /// + /// The to add the middleware to. + /// A reference to the after the operation has completed. + public static IApplicationBuilder UseHeaderPropagation(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (app.ApplicationServices.GetService() == null) + { + throw new InvalidOperationException(_unableToFindServices); + } + + return app.UseMiddleware(); + } + } +} diff --git a/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationHttpClientBuilderExtensions.cs b/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationHttpClientBuilderExtensions.cs new file mode 100644 index 000000000000..f71614392f10 --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationHttpClientBuilderExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.HeaderPropagation; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class HeaderPropagationHttpClientBuilderExtensions + { + /// + /// Adds a message handler for propagating headers collected by the to a outgoing request. + /// + /// The to add the message handler to. + /// The so that additional calls can be chained. + public static IHttpClientBuilder AddHeaderPropagation(this IHttpClientBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddHeaderPropagation(); + + builder.AddHttpMessageHandler(); + + return builder; + } + } +} diff --git a/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationServiceCollectionExtensions.cs b/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationServiceCollectionExtensions.cs new file mode 100644 index 000000000000..b175cd36e90b --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/DependencyInjection/HeaderPropagationServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net.Http; +using Microsoft.AspNetCore.HeaderPropagation; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class HeaderPropagationServiceCollectionExtensions + { + /// + /// Adds services required for propagating headers to a . + /// + /// The to add the services to. + /// The so that additional calls can be chained. + public static IServiceCollection AddHeaderPropagation(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.TryAddSingleton(); + services.TryAddTransient(); + + return services; + } + + /// + /// Adds services required for propagating headers to a . + /// + /// The to add the services to. + /// A delegate used to configure the . + /// The so that additional calls can be chained. + public static IServiceCollection AddHeaderPropagation(this IServiceCollection services, Action configureOptions) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configureOptions == null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + services.Configure(configureOptions); + services.AddHeaderPropagation(); + + return services; + } + } +} diff --git a/src/Middleware/HeaderPropagation/src/HeaderPropagationEntry.cs b/src/Middleware/HeaderPropagation/src/HeaderPropagationEntry.cs new file mode 100644 index 000000000000..5c9257400d2d --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/HeaderPropagationEntry.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.HeaderPropagation +{ + /// + /// Define the configuration of a header for the . + /// + public class HeaderPropagationEntry + { + /// + /// Gets or sets the name of the header to be used by the for the + /// outbound http requests. + /// + /// + /// If is present, the value of the header in the outbound calls will be the one + /// returned by the factory or, if the factory returns an empty value, the header will be omitted. + /// Otherwise, it will be the value of the header in the incoming request named as the key of this entry in + /// or, if missing or empty, the value specified in + /// or, if the is empty, it will not be + /// added to the outbound calls. + /// + public string OutboundHeaderName { get; set; } + + /// + /// Gets or sets the default value to be used when the header in the incoming request is missing or empty. + /// + /// + /// This value is ignored when is set. + /// When it is it has no effect and, if the header is missing or empty in the + /// incoming request, it will not be added to outbound calls. + /// + public StringValues DefaultValue { get; set; } + + /// + /// Gets or sets the value factory to be used. + /// It gets as input the inbound header name for this entry as defined in + /// and the of the current request. + /// + /// + /// When present, the factory is the only method used to set the value. + /// The factory should return to not add the header. + /// When not present, the value will be taken from the header in the incoming request named as the key of this + /// entry in or, if missing or empty, it will be the values + /// specified in or, if the is empty, the header will not + /// be added to the outbound calls. + /// Please note the factory is called only once per incoming request and the same value will be used by all the + /// outbound calls. + /// + public Func ValueFactory { get; set; } + } +} diff --git a/src/Middleware/HeaderPropagation/src/HeaderPropagationMessageHandler.cs b/src/Middleware/HeaderPropagation/src/HeaderPropagationMessageHandler.cs new file mode 100644 index 000000000000..ff47b5ba48d7 --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/HeaderPropagationMessageHandler.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.HeaderPropagation +{ + /// + /// A message handler for propagating headers collected by the to a outgoing request. + /// + public class HeaderPropagationMessageHandler : DelegatingHandler + { + private readonly HeaderPropagationValues _values; + private readonly HeaderPropagationOptions _options; + + /// + /// Creates a new instance of the . + /// + /// The options that define which headers are propagated. + /// The values of the headers to be propagated populated by the + /// . + public HeaderPropagationMessageHandler(IOptions options, HeaderPropagationValues values) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _options = options.Value; + + _values = values ?? throw new ArgumentNullException(nameof(values)); + } + + /// + /// Sends an HTTP request to the inner handler to send to the server as an asynchronous operation, after adding + /// the propagated headers. + /// + /// + /// If an header with the same name is already present in the request, even if empty, the corresponding + /// propagated header will not be added. + /// + /// The HTTP request message to send to the server. + /// A cancellation token to cancel operation. + /// The task object representing the asynchronous operation. + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + foreach ((var headerName, var entry) in _options.Headers) + { + var outputName = string.IsNullOrEmpty(entry?.OutboundHeaderName) ? headerName : entry.OutboundHeaderName; + + if (!request.Headers.Contains(outputName) && + _values.Headers.TryGetValue(headerName, out var values) && + !StringValues.IsNullOrEmpty(values)) + { + request.Headers.TryAddWithoutValidation(outputName, (string[])values); + } + } + + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Middleware/HeaderPropagation/src/HeaderPropagationMiddleware.cs b/src/Middleware/HeaderPropagation/src/HeaderPropagationMiddleware.cs new file mode 100644 index 000000000000..4183faa0fa3d --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/HeaderPropagationMiddleware.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.HeaderPropagation +{ + /// + /// A Middleware for propagating headers to a . + /// + public class HeaderPropagationMiddleware + { + private readonly RequestDelegate _next; + private readonly HeaderPropagationOptions _options; + private readonly HeaderPropagationValues _values; + + public HeaderPropagationMiddleware(RequestDelegate next, IOptions options, HeaderPropagationValues values) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + _options = options.Value; + + _values = values ?? throw new ArgumentNullException(nameof(values)); + } + + public Task Invoke(HttpContext context) + { + foreach ((var headerName, var entry) in _options.Headers) + { + var values = GetValues(headerName, entry, context); + + if (!StringValues.IsNullOrEmpty(values)) + { + _values.Headers.TryAdd(headerName, values); + } + } + + return _next.Invoke(context); + } + + private static StringValues GetValues(string headerName, HeaderPropagationEntry entry, HttpContext context) + { + if (entry?.ValueFactory != null) + { + return entry.ValueFactory(headerName, context); + } + + if (context.Request.Headers.TryGetValue(headerName, out var values) + && !StringValues.IsNullOrEmpty(values)) + { + return values; + } + + return entry != null ? entry.DefaultValue : StringValues.Empty; + } + } +} diff --git a/src/Middleware/HeaderPropagation/src/HeaderPropagationOptions.cs b/src/Middleware/HeaderPropagation/src/HeaderPropagationOptions.cs new file mode 100644 index 000000000000..ffd04583f0c9 --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/HeaderPropagationOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.HeaderPropagation +{ + /// + /// Provides configuration for the . + /// + public class HeaderPropagationOptions + { + /// + /// Gets or sets the headers to be collected by the + /// and to be propagated by the . + /// + public IDictionary Headers { get; set; } = new Dictionary(); + } +} diff --git a/src/Middleware/HeaderPropagation/src/HeaderPropagationValues.cs b/src/Middleware/HeaderPropagation/src/HeaderPropagationValues.cs new file mode 100644 index 000000000000..8c3d9ab26d0a --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/HeaderPropagationValues.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.HeaderPropagation +{ + /// + /// Contains the headers values for the . + /// + public class HeaderPropagationValues + { + private readonly static AsyncLocal> _headers = new AsyncLocal>(); + + /// + /// Gets the headers values collected by the from the current request that can be propagated. + /// + public IDictionary Headers + { + get + { + return _headers.Value ?? (_headers.Value = new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + } + } +} diff --git a/src/Middleware/HeaderPropagation/src/Microsoft.AspNetCore.HeaderPropagation.csproj b/src/Middleware/HeaderPropagation/src/Microsoft.AspNetCore.HeaderPropagation.csproj new file mode 100644 index 000000000000..5ae4e64cc2f3 --- /dev/null +++ b/src/Middleware/HeaderPropagation/src/Microsoft.AspNetCore.HeaderPropagation.csproj @@ -0,0 +1,22 @@ + + + + ASP.NET Core middleware to propagate HTTP headers from the incoming request to the outgoing HTTP Client requests + netcoreapp3.0 + true + $(NoWarn);CS1591 + true + aspnetcore;httpclient + + + + + + + + + + + + + diff --git a/src/Middleware/HeaderPropagation/test/HeaderPropagationIntegrationTest.cs b/src/Middleware/HeaderPropagation/test/HeaderPropagationIntegrationTest.cs new file mode 100644 index 000000000000..207ececc6338 --- /dev/null +++ b/src/Middleware/HeaderPropagation/test/HeaderPropagationIntegrationTest.cs @@ -0,0 +1,109 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.HeaderPropagation.Tests +{ + public class HeaderPropagationIntegrationTest + { + [Fact] + public async Task HeaderInRequest_AddCorrectValue() + { + // Arrange + var handler = new SimpleHandler(); + var builder = CreateBuilder(c => + c.Headers.Add("in", new HeaderPropagationEntry + { + OutboundHeaderName = "out", + }), + handler); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(); + request.Headers.Add("in", "test"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(handler.Headers.Contains("out")); + Assert.Equal(new[] { "test" }, handler.Headers.GetValues("out")); + } + + [Fact] + public void Builder_UseHeaderPropagation_Without_AddHeaderPropagation_Throws() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHeaderPropagation(); + }); + + var exception = Assert.Throws(() => new TestServer(builder)); + Assert.Equal( + "Unable to find the required services. Please add all the required services by calling 'IServiceCollection.AddHeaderPropagation' inside the call to 'ConfigureServices(...)' in the application startup code.", + exception.Message); + } + + private IWebHostBuilder CreateBuilder(Action configure, HttpMessageHandler primaryHandler) + { + return new WebHostBuilder() + .Configure(app => + { + app.UseHeaderPropagation(); + app.UseMiddleware(); + }) + .ConfigureServices(services => + { + services.AddHttpClient("example.com", c => c.BaseAddress = new Uri("http://example.com")) + .ConfigureHttpMessageHandlerBuilder(b => + { + b.PrimaryHandler = primaryHandler; + }) + .AddHeaderPropagation(); + services.AddHeaderPropagation(configure); + }); + } + + private class SimpleHandler : DelegatingHandler + { + public HttpHeaders Headers { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Headers = request.Headers; + return Task.FromResult(new HttpResponseMessage()); + } + } + + private class SimpleMiddleware + { + private readonly IHttpClientFactory _httpClientFactory; + + public SimpleMiddleware(RequestDelegate next, IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public Task InvokeAsync(HttpContext _) + { + var client = _httpClientFactory.CreateClient("example.com"); + return client.GetAsync(""); + } + } + } +} diff --git a/src/Middleware/HeaderPropagation/test/HeaderPropagationMessageHandlerTest.cs b/src/Middleware/HeaderPropagation/test/HeaderPropagationMessageHandlerTest.cs new file mode 100644 index 000000000000..320cc108f1a7 --- /dev/null +++ b/src/Middleware/HeaderPropagation/test/HeaderPropagationMessageHandlerTest.cs @@ -0,0 +1,180 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.HeaderPropagation.Tests +{ + public class HeaderPropagationMessageHandlerTest + { + public HeaderPropagationMessageHandlerTest() + { + Handler = new SimpleHandler(); + + State = new HeaderPropagationValues(); + Configuration = new HeaderPropagationOptions(); + + var headerPropagationMessageHandler = + new HeaderPropagationMessageHandler(Options.Create(Configuration), State) + { + InnerHandler = Handler + }; + + Client = new HttpClient(headerPropagationMessageHandler) + { + BaseAddress = new Uri("http://example.com") + }; + } + + private SimpleHandler Handler { get; } + public HeaderPropagationValues State { get; set; } + public HeaderPropagationOptions Configuration { get; set; } + public HttpClient Client { get; set; } + + [Fact] + public async Task HeaderInState_AddCorrectValue() + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry { OutboundHeaderName = "out" }); + State.Headers.Add("in", "test"); + + // Act + await Client.SendAsync(new HttpRequestMessage()); + + // Assert + Assert.True(Handler.Headers.Contains("out")); + Assert.Equal(new[] { "test" }, Handler.Headers.GetValues("out")); + } + + [Fact] + public async Task HeaderInState_NoOutputName_UseInputName() + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry()); + State.Headers.Add("in", "test"); + + // Act + await Client.SendAsync(new HttpRequestMessage()); + + // Assert + Assert.True(Handler.Headers.Contains("in")); + Assert.Equal(new[] { "test" }, Handler.Headers.GetValues("in")); + } + + [Fact] + public async Task NoHeaderInState_DoesNotAddIt() + { + // Arrange + Configuration.Headers.Add("inout", new HeaderPropagationEntry()); + + // Act + await Client.SendAsync(new HttpRequestMessage()); + + // Assert + Assert.Empty(Handler.Headers); + } + + [Fact] + public async Task HeaderInState_NotInOptions_DoesNotAddIt() + { + // Arrange + State.Headers.Add("inout", "test"); + + // Act + await Client.SendAsync(new HttpRequestMessage()); + + // Assert + Assert.Empty(Handler.Headers); + } + + [Fact] + public async Task MultipleHeadersInState_AddsAll() + { + // Arrange + Configuration.Headers.Add("inout", new HeaderPropagationEntry()); + Configuration.Headers.Add("another", new HeaderPropagationEntry()); + State.Headers.Add("inout", "test"); + State.Headers.Add("another", "test2"); + + // Act + await Client.SendAsync(new HttpRequestMessage()); + + // Assert + Assert.True(Handler.Headers.Contains("inout")); + Assert.True(Handler.Headers.Contains("another")); + Assert.Equal(new[] { "test" }, Handler.Headers.GetValues("inout")); + Assert.Equal(new[] { "test2" }, Handler.Headers.GetValues("another")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task HeaderEmptyInState_DoNotAddIt(string headerValue) + { + // Arrange + Configuration.Headers.Add("inout", new HeaderPropagationEntry()); + State.Headers.Add("inout", headerValue); + + // Act + await Client.SendAsync(new HttpRequestMessage()); + + // Assert + Assert.False(Handler.Headers.Contains("inout")); + } + + [Theory] + [InlineData("", new[] { "" })] + [InlineData(null, new[] { "" })] + [InlineData("42", new[] { "42" })] + public async Task HeaderInState_HeaderAlreadyInOutgoingRequest(string outgoingValue, + string[] expectedValues) + { + // Arrange + State.Headers.Add("inout", "test"); + Configuration.Headers.Add("inout", new HeaderPropagationEntry()); + + var request = new HttpRequestMessage(); + request.Headers.Add("inout", outgoingValue); + + // Act + await Client.SendAsync(request); + + // Assert + Assert.True(Handler.Headers.Contains("inout")); + Assert.Equal(expectedValues, Handler.Headers.GetValues("inout")); + } + + [Fact] + public async Task NullEntryInConfiguration_AddCorrectValue() + { + // Arrange + Configuration.Headers.Add("in", null); + State.Headers.Add("in", "test"); + + // Act + await Client.SendAsync(new HttpRequestMessage()); + + // Assert + Assert.True(Handler.Headers.Contains("in")); + Assert.Equal(new[] { "test" }, Handler.Headers.GetValues("in")); + } + + private class SimpleHandler : DelegatingHandler + { + public HttpHeaders Headers { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + Headers = request.Headers; + return Task.FromResult(new HttpResponseMessage()); + } + } + } +} diff --git a/src/Middleware/HeaderPropagation/test/HeaderPropagationMiddlewareTest.cs b/src/Middleware/HeaderPropagation/test/HeaderPropagationMiddlewareTest.cs new file mode 100644 index 000000000000..ad9e8e10d2ee --- /dev/null +++ b/src/Middleware/HeaderPropagation/test/HeaderPropagationMiddlewareTest.cs @@ -0,0 +1,217 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.AspNetCore.HeaderPropagation.Tests +{ + public class HeaderPropagationMiddlewareTest + { + public HeaderPropagationMiddlewareTest() + { + Context = new DefaultHttpContext(); + Next = ctx => Task.CompletedTask; + Configuration = new HeaderPropagationOptions(); + State = new HeaderPropagationValues(); + Middleware = new HeaderPropagationMiddleware(Next, + new OptionsWrapper(Configuration), + State); + } + + public DefaultHttpContext Context { get; set; } + public RequestDelegate Next { get; set; } + public HeaderPropagationOptions Configuration { get; set; } + public HeaderPropagationValues State { get; set; } + public HeaderPropagationMiddleware Middleware { get; set; } + + [Fact] + public async Task HeaderInRequest_AddCorrectValue() + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry()); + Context.Request.Headers.Add("in", "test"); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Contains("in", State.Headers.Keys); + Assert.Equal(new[] { "test" }, State.Headers["in"]); + } + + [Fact] + public async Task NoHeaderInRequest_DoesNotAddIt() + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry()); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Empty(State.Headers); + } + + [Fact] + public async Task HeaderInRequest_NotInOptions_DoesNotAddIt() + { + // Arrange + Context.Request.Headers.Add("in", "test"); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Empty(State.Headers); + } + + [Fact] + public async Task MultipleHeadersInRequest_AddAllHeaders() + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry()); + Configuration.Headers.Add("another", new HeaderPropagationEntry()); + Context.Request.Headers.Add("in", "test"); + Context.Request.Headers.Add("another", "test2"); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Contains("in", State.Headers.Keys); + Assert.Equal(new[] { "test" }, State.Headers["in"]); + Assert.Contains("another", State.Headers.Keys); + Assert.Equal(new[] { "test2" }, State.Headers["another"]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task HeaderEmptyInRequest_DoesNotAddIt(string headerValue) + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry()); + Context.Request.Headers.Add("in", headerValue); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.DoesNotContain("in", State.Headers.Keys); + } + + [Theory] + [InlineData(new[] { "default" }, new[] { "default" })] + [InlineData(new[] { "default", "other" }, new[] { "default", "other" })] + public async Task NoHeaderInRequest_AddsDefaultValue(string[] defaultValues, + string[] expectedValues) + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry { DefaultValue = defaultValues }); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Contains("in", State.Headers.Keys); + Assert.Equal(expectedValues, State.Headers["in"]); + } + + [Theory] + [InlineData(new[] { "default" }, new[] { "default" })] + [InlineData(new[] { "default", "other" }, new[] { "default", "other" })] + public async Task UsesValueFactory(string[] factoryValues, + string[] expectedValues) + { + // Arrange + string receivedName = null; + HttpContext receivedContext = null; + Configuration.Headers.Add("in", new HeaderPropagationEntry + { + DefaultValue = "no", + ValueFactory = (name, ctx) => + { + receivedName = name; + receivedContext = ctx; + return factoryValues; + } + }); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Contains("in", State.Headers.Keys); + Assert.Equal(expectedValues, State.Headers["in"]); + Assert.Equal("in", receivedName); + Assert.Same(Context, receivedContext); + } + + [Fact] + public async Task PreferValueFactory_OverDefaultValuesAndRequestHeader() + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry + { + DefaultValue = "no", + ValueFactory = (name, ctx) => "test" + }); + Context.Request.Headers.Add("in", "no"); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Contains("in", State.Headers.Keys); + Assert.Equal("test", State.Headers["in"]); + } + + [Fact] + public async Task EmptyValuesFromValueFactory_DoesNotAddIt() + { + // Arrange + Configuration.Headers.Add("in", new HeaderPropagationEntry + { + ValueFactory = (name, ctx) => StringValues.Empty + }); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.DoesNotContain("in", State.Headers.Keys); + } + + [Fact] + public async Task NullEntryInConfiguration_HeaderInRequest_AddsCorrectValue() + { + // Arrange + Configuration.Headers.Add("in", null); + Context.Request.Headers.Add("in", "test"); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.Contains("in", State.Headers.Keys); + Assert.Equal(new[] { "test" }, State.Headers["in"]); + } + + [Fact] + public async Task NullEntryInConfiguration_NoHeaderInRequest_DoesNotAddHeader() + { + // Arrange + Configuration.Headers.Add("in", null); + + // Act + await Middleware.Invoke(Context); + + // Assert + Assert.DoesNotContain("in", State.Headers.Keys); + } + } +} diff --git a/src/Middleware/HeaderPropagation/test/Microsoft.AspNetCore.HeaderPropagation.Tests.csproj b/src/Middleware/HeaderPropagation/test/Microsoft.AspNetCore.HeaderPropagation.Tests.csproj new file mode 100644 index 000000000000..39ce2903ea39 --- /dev/null +++ b/src/Middleware/HeaderPropagation/test/Microsoft.AspNetCore.HeaderPropagation.Tests.csproj @@ -0,0 +1,13 @@ + + + + netcoreapp3.0 + + + + + + + + + diff --git a/src/Middleware/Middleware.sln b/src/Middleware/Middleware.sln index 4eefacd361e5..27500743f636 100644 --- a/src/Middleware/Middleware.sln +++ b/src/Middleware/Middleware.sln @@ -269,6 +269,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SpaSer EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.NodeServices.Tests", "NodeServices\test\Microsoft.AspNetCore.NodeServices.Tests.csproj", "{B04E9CB6-0D1C-4C21-B626-89B6926A491F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HeaderPropagation", "HeaderPropagation", "{0437D207-864E-429C-92B4-9D08D290188C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HeaderPropagation", "HeaderPropagation\src\Microsoft.AspNetCore.HeaderPropagation.csproj", "{D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HeaderPropagation.Tests", "HeaderPropagation\test\Microsoft.AspNetCore.HeaderPropagation.Tests.csproj", "{7E18FA09-5E08-4E41-836F-25C94B60C608}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{8CDBD9C6-96D8-4987-AFCD-D248FBC7F02D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1479,6 +1487,30 @@ Global {B04E9CB6-0D1C-4C21-B626-89B6926A491F}.Release|x64.Build.0 = Release|Any CPU {B04E9CB6-0D1C-4C21-B626-89B6926A491F}.Release|x86.ActiveCfg = Release|Any CPU {B04E9CB6-0D1C-4C21-B626-89B6926A491F}.Release|x86.Build.0 = Release|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Debug|x64.ActiveCfg = Debug|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Debug|x64.Build.0 = Debug|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Debug|x86.ActiveCfg = Debug|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Debug|x86.Build.0 = Debug|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Release|Any CPU.Build.0 = Release|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Release|x64.ActiveCfg = Release|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Release|x64.Build.0 = Release|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Release|x86.ActiveCfg = Release|Any CPU + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084}.Release|x86.Build.0 = Release|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Debug|x64.Build.0 = Debug|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Debug|x86.Build.0 = Debug|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Release|Any CPU.Build.0 = Release|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Release|x64.ActiveCfg = Release|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Release|x64.Build.0 = Release|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Release|x86.ActiveCfg = Release|Any CPU + {7E18FA09-5E08-4E41-836F-25C94B60C608}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1595,6 +1627,9 @@ Global {D9D02772-1D53-45C3-B2CC-888F9978958C} = {D6FA4ABE-E685-4EDD-8B06-D8777E76B472} {5D5B7E54-9323-498A-8983-E9BDFA3B2D07} = {D6FA4ABE-E685-4EDD-8B06-D8777E76B472} {B04E9CB6-0D1C-4C21-B626-89B6926A491F} = {17B409B3-7EC6-49D8-847E-CFAA319E01B5} + {D66BD4A3-DA19-413B-8FC5-4BCCFB03E084} = {0437D207-864E-429C-92B4-9D08D290188C} + {7E18FA09-5E08-4E41-836F-25C94B60C608} = {8CDBD9C6-96D8-4987-AFCD-D248FBC7F02D} + {8CDBD9C6-96D8-4987-AFCD-D248FBC7F02D} = {0437D207-864E-429C-92B4-9D08D290188C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {83786312-A93B-4BB4-AB06-7C6913A59AFA}