_unrecognizedMediaType =
+ LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnrecognizedMediaType"), "Unrecognized Content-Type for body.");
+
+ public static void RequestLog(this ILogger logger, HttpRequestLog requestLog) => logger.Log(
+ LogLevel.Information,
+ new EventId(1, "RequestLogLog"),
+ requestLog,
+ exception: null,
+ formatter: HttpRequestLog.Callback);
+ public static void ResponseLog(this ILogger logger, HttpResponseLog responseLog) => logger.Log(
+ LogLevel.Information,
+ new EventId(2, "ResponseLog"),
+ responseLog,
+ exception: null,
+ formatter: HttpResponseLog.Callback);
+ public static void RequestBody(this ILogger logger, string body) => _requestBody(logger, body, null);
+ public static void ResponseBody(this ILogger logger, string body) => _responseBody(logger, body, null);
+ public static void DecodeFailure(this ILogger logger, Exception ex) => _decodeFailure(logger, ex);
+ public static void UnrecognizedMediaType(this ILogger logger) => _unrecognizedMediaType(logger, null);
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/HttpLoggingFields.cs b/src/Middleware/HttpLogging/src/HttpLoggingFields.cs
new file mode 100644
index 000000000000..dab6c3208491
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/HttpLoggingFields.cs
@@ -0,0 +1,176 @@
+// 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.AspNetCore.Http.Features;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ ///
+ /// Flags used to control which parts of the
+ /// request and response are logged.
+ ///
+ [Flags]
+ public enum HttpLoggingFields : long
+ {
+ ///
+ /// No logging.
+ ///
+ None = 0x0,
+
+ ///
+ /// Flag for logging the HTTP Request Path, which includes both the
+ /// and .
+ ///
+ /// For example:
+ /// Path: /index
+ /// PathBase: /app
+ ///
+ ///
+ RequestPath = 0x1,
+
+ ///
+ /// Flag for logging the HTTP Request .
+ ///
+ /// For example:
+ /// Query: ?index=1
+ ///
+ ///
+ RequestQuery = 0x2,
+
+ ///
+ /// Flag for logging the HTTP Request .
+ ///
+ /// For example:
+ /// Protocol: HTTP/1.1
+ ///
+ ///
+ RequestProtocol = 0x4,
+
+ ///
+ /// Flag for logging the HTTP Request .
+ ///
+ /// For example:
+ /// Method: GET
+ ///
+ ///
+ RequestMethod = 0x8,
+
+ ///
+ /// Flag for logging the HTTP Request .
+ ///
+ /// For example:
+ /// Scheme: https
+ ///
+ ///
+ RequestScheme = 0x10,
+
+ ///
+ /// Flag for logging the HTTP Response .
+ ///
+ /// For example:
+ /// StatusCode: 200
+ ///
+ ///
+ ResponseStatusCode = 0x20,
+
+ ///
+ /// Flag for logging the HTTP Request .
+ /// Request Headers are logged as soon as the middleware is invoked.
+ /// Headers are redacted by default with the character '[Redacted]' unless specified in
+ /// the .
+ ///
+ /// For example:
+ /// Connection: keep-alive
+ /// My-Custom-Request-Header: [Redacted]
+ ///
+ ///
+ RequestHeaders = 0x40,
+
+ ///
+ /// Flag for logging the HTTP Response .
+ /// Response Headers are logged when the is written to
+ /// or when
+ /// is called.
+ /// Headers are redacted by default with the character '[Redacted]' unless specified in
+ /// the .
+ ///
+ /// For example:
+ /// Content-Length: 16
+ /// My-Custom-Response-Header: [Redacted]
+ ///
+ ///
+ ResponseHeaders = 0x80,
+
+ ///
+ /// Flag for logging the HTTP Request .
+ /// Request Trailers are currently not logged.
+ ///
+ RequestTrailers = 0x100,
+
+ ///
+ /// Flag for logging the HTTP Response .
+ /// Response Trailers are currently not logged.
+ ///
+ ResponseTrailers = 0x200,
+
+ ///
+ /// Flag for logging the HTTP Request .
+ /// Logging the request body has performance implications, as it requires buffering
+ /// the entire request body up to .
+ ///
+ RequestBody = 0x400,
+
+ ///
+ /// Flag for logging the HTTP Response .
+ /// Logging the response body has performance implications, as it requires buffering
+ /// the entire response body up to .
+ ///
+ ResponseBody = 0x800,
+
+ ///
+ /// Flag for logging a collection of HTTP Request properties,
+ /// including , , ,
+ /// , and .
+ ///
+ RequestProperties = RequestPath | RequestQuery | RequestProtocol | RequestMethod | RequestScheme,
+
+ ///
+ /// Flag for logging HTTP Request properties and headers.
+ /// Includes and
+ ///
+ RequestPropertiesAndHeaders = RequestProperties | RequestHeaders,
+
+ ///
+ /// Flag for logging HTTP Response properties and headers.
+ /// Includes and
+ ///
+ ResponsePropertiesAndHeaders = ResponseStatusCode | ResponseHeaders,
+
+ ///
+ /// Flag for logging the entire HTTP Request.
+ /// Includes and .
+ /// Logging the request body has performance implications, as it requires buffering
+ /// the entire request body up to .
+ ///
+ Request = RequestPropertiesAndHeaders | RequestBody,
+
+ ///
+ /// Flag for logging the entire HTTP Response.
+ /// Includes and .
+ /// Logging the response body has performance implications, as it requires buffering
+ /// the entire response body up to .
+ ///
+ Response = ResponseStatusCode | ResponseHeaders | ResponseBody,
+
+ ///
+ /// Flag for logging both the HTTP Request and Response.
+ /// Includes and .
+ /// Logging the request and response body has performance implications, as it requires buffering
+ /// the entire request and response body up to the
+ /// and .
+ ///
+ All = Request | Response
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/HttpLoggingMiddleware.cs b/src/Middleware/HttpLogging/src/HttpLoggingMiddleware.cs
new file mode 100644
index 000000000000..ba56129f14b7
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/HttpLoggingMiddleware.cs
@@ -0,0 +1,247 @@
+// 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.Buffers;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ ///
+ /// Middleware that logs HTTP requests and HTTP responses.
+ ///
+ internal sealed class HttpLoggingMiddleware
+ {
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+ private readonly IOptionsMonitor _options;
+ private const int DefaultRequestFieldsMinusHeaders = 7;
+ private const int DefaultResponseFieldsMinusHeaders = 2;
+ private const string Redacted = "[Redacted]";
+
+ ///
+ /// Initializes .
+ ///
+ ///
+ ///
+ ///
+ public HttpLoggingMiddleware(RequestDelegate next, IOptionsMonitor options, ILogger logger)
+ {
+ _next = next ?? throw new ArgumentNullException(nameof(next));
+
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ if (logger == null)
+ {
+ throw new ArgumentNullException(nameof(logger));
+ }
+
+ _options = options;
+ _logger = logger;
+ }
+
+ ///
+ /// Invokes the .
+ ///
+ ///
+ /// HttpResponseLog.cs
+ public Task Invoke(HttpContext context)
+ {
+ if (!_logger.IsEnabled(LogLevel.Information))
+ {
+ // Logger isn't enabled.
+ return _next(context);
+ }
+
+ return InvokeInternal(context);
+ }
+
+ private async Task InvokeInternal(HttpContext context)
+ {
+ var options = _options.CurrentValue;
+ RequestBufferingStream? requestBufferingStream = null;
+ Stream? originalBody = null;
+
+ if ((HttpLoggingFields.Request & options.LoggingFields) != HttpLoggingFields.None)
+ {
+ var request = context.Request;
+ var list = new List>(
+ request.Headers.Count + DefaultRequestFieldsMinusHeaders);
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.RequestProtocol))
+ {
+ AddToList(list, nameof(request.Protocol), request.Protocol);
+ }
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.RequestMethod))
+ {
+ AddToList(list, nameof(request.Method), request.Method);
+ }
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.RequestScheme))
+ {
+ AddToList(list, nameof(request.Scheme), request.Scheme);
+ }
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.RequestPath))
+ {
+ AddToList(list, nameof(request.PathBase), request.PathBase);
+ AddToList(list, nameof(request.Path), request.Path);
+ }
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.RequestQuery))
+ {
+ AddToList(list, nameof(request.QueryString), request.QueryString.Value);
+ }
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.RequestHeaders))
+ {
+ FilterHeaders(list, request.Headers, options._internalRequestHeaders);
+ }
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.RequestBody))
+ {
+ if (MediaTypeHelpers.TryGetEncodingForMediaType(request.ContentType,
+ options.MediaTypeOptions.MediaTypeStates,
+ out var encoding))
+ {
+ originalBody = request.Body;
+ requestBufferingStream = new RequestBufferingStream(
+ request.Body,
+ options.RequestBodyLogLimit,
+ _logger,
+ encoding);
+ request.Body = requestBufferingStream;
+ }
+ else
+ {
+ _logger.UnrecognizedMediaType();
+ }
+ }
+
+ var httpRequestLog = new HttpRequestLog(list);
+
+ _logger.RequestLog(httpRequestLog);
+ }
+
+ ResponseBufferingStream? responseBufferingStream = null;
+ IHttpResponseBodyFeature? originalBodyFeature = null;
+
+ try
+ {
+ var response = context.Response;
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.ResponseBody))
+ {
+ originalBodyFeature = context.Features.Get()!;
+
+ // TODO pool these.
+ responseBufferingStream = new ResponseBufferingStream(originalBodyFeature,
+ options.ResponseBodyLogLimit,
+ _logger,
+ context,
+ options.MediaTypeOptions.MediaTypeStates,
+ options);
+ response.Body = responseBufferingStream;
+ context.Features.Set(responseBufferingStream);
+ }
+
+ await _next(context);
+
+ if (requestBufferingStream?.HasLogged == false)
+ {
+ // If the middleware pipeline didn't read until 0 was returned from readasync,
+ // make sure we log the request body.
+ requestBufferingStream.LogRequestBody();
+ }
+
+ if (responseBufferingStream == null || responseBufferingStream.FirstWrite == false)
+ {
+ // No body, write headers here.
+ LogResponseHeaders(response, options, _logger);
+ }
+
+ if (responseBufferingStream != null)
+ {
+ var responseBody = responseBufferingStream.GetString(responseBufferingStream.Encoding);
+ if (!string.IsNullOrEmpty(responseBody))
+ {
+ _logger.ResponseBody(responseBody);
+ }
+ }
+ }
+ finally
+ {
+ responseBufferingStream?.Dispose();
+
+ if (originalBodyFeature != null)
+ {
+ context.Features.Set(originalBodyFeature);
+ }
+
+ requestBufferingStream?.Dispose();
+
+ if (originalBody != null)
+ {
+ context.Request.Body = originalBody;
+ }
+ }
+ }
+
+ private static void AddToList(List> list, string key, string? value)
+ {
+ list.Add(new KeyValuePair(key, value));
+ }
+
+ public static void LogResponseHeaders(HttpResponse response, HttpLoggingOptions options, ILogger logger)
+ {
+ var list = new List>(
+ response.Headers.Count + DefaultResponseFieldsMinusHeaders);
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.ResponseStatusCode))
+ {
+ list.Add(new KeyValuePair(nameof(response.StatusCode),
+ response.StatusCode.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ if (options.LoggingFields.HasFlag(HttpLoggingFields.ResponseHeaders))
+ {
+ FilterHeaders(list, response.Headers, options._internalResponseHeaders);
+ }
+
+ var httpResponseLog = new HttpResponseLog(list);
+
+ logger.ResponseLog(httpResponseLog);
+ }
+
+ internal static void FilterHeaders(List> keyValues,
+ IHeaderDictionary headers,
+ HashSet allowedHeaders)
+ {
+ foreach (var (key, value) in headers)
+ {
+ if (!allowedHeaders.Contains(key))
+ {
+ // Key is not among the "only listed" headers.
+ keyValues.Add(new KeyValuePair(key, Redacted));
+ continue;
+ }
+ keyValues.Add(new KeyValuePair(key, value.ToString()));
+ }
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/HttpLoggingOptions.cs b/src/Middleware/HttpLogging/src/HttpLoggingOptions.cs
new file mode 100644
index 000000000000..800729b22aba
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/HttpLoggingOptions.cs
@@ -0,0 +1,78 @@
+// 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.Text;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ ///
+ /// Options for the .
+ ///
+ public sealed class HttpLoggingOptions
+ {
+ ///
+ /// Fields to log for the Request and Response. Defaults to logging request and response properties and headers.
+ ///
+ public HttpLoggingFields LoggingFields { get; set; } = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.ResponsePropertiesAndHeaders;
+
+ ///
+ /// Request header values that are allowed to be logged.
+ ///
+ /// If a request header is not present in the ,
+ /// the header name will be logged with a redacted value.
+ ///
+ ///
+ public ISet RequestHeaders => _internalRequestHeaders;
+
+ internal HashSet _internalRequestHeaders = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ HeaderNames.Accept,
+ HeaderNames.AcceptEncoding,
+ HeaderNames.AcceptLanguage,
+ HeaderNames.Allow,
+ HeaderNames.Connection,
+ HeaderNames.ContentLength,
+ HeaderNames.ContentType,
+ HeaderNames.Host,
+ HeaderNames.UserAgent
+ };
+
+ ///
+ /// Response header values that are allowed to be logged.
+ ///
+ /// If a response header is not present in the ,
+ /// the header name will be logged with a redacted value.
+ ///
+ ///
+ public ISet ResponseHeaders => _internalResponseHeaders;
+
+ internal HashSet _internalResponseHeaders = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ HeaderNames.ContentLength,
+ HeaderNames.ContentType,
+ HeaderNames.TransferEncoding
+ };
+
+ ///
+ /// Options for configuring encodings for a specific media type.
+ ///
+ /// If the request or response do not match the supported media type,
+ /// the response body will not be logged.
+ ///
+ ///
+ public MediaTypeOptions MediaTypeOptions { get; } = MediaTypeOptions.BuildDefaultMediaTypeOptions();
+
+ ///
+ /// Maximum request body size to log (in bytes). Defaults to 32 KB.
+ ///
+ public int RequestBodyLogLimit { get; set; } = 32 * 1024;
+
+ ///
+ /// Maximum response body size to log (in bytes). Defaults to 32 KB.
+ ///
+ public int ResponseBodyLogLimit { get; set; } = 32 * 1024;
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/HttpLoggingServicesExtensions.cs b/src/Middleware/HttpLogging/src/HttpLoggingServicesExtensions.cs
new file mode 100644
index 000000000000..1d755dcfc8ac
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/HttpLoggingServicesExtensions.cs
@@ -0,0 +1,35 @@
+// 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.HttpLogging;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// Extension methods for the HttpLogging middleware.
+ ///
+ public static class HttpLoggingServicesExtensions
+ {
+ ///
+ /// Adds HTTP Logging services.
+ ///
+ /// The for adding services.
+ /// A delegate to configure the .
+ ///
+ public static IServiceCollection AddHttpLogging(this IServiceCollection services, Action configureOptions)
+ {
+ if (services == null)
+ {
+ throw new ArgumentNullException(nameof(services));
+ }
+ if (configureOptions == null)
+ {
+ throw new ArgumentNullException(nameof(configureOptions));
+ }
+
+ services.Configure(configureOptions);
+ return services;
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/HttpRequestLog.cs b/src/Middleware/HttpLogging/src/HttpRequestLog.cs
new file mode 100644
index 000000000000..2dd608e4c32e
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/HttpRequestLog.cs
@@ -0,0 +1,74 @@
+// 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;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ internal sealed class HttpRequestLog : IReadOnlyList>
+ {
+ private readonly List> _keyValues;
+ private string? _cachedToString;
+
+ internal static readonly Func Callback = (state, exception) => ((HttpRequestLog)state).ToString();
+
+ public HttpRequestLog(List> keyValues)
+ {
+ _keyValues = keyValues;
+ }
+
+ public KeyValuePair this[int index] => _keyValues[index];
+
+ public int Count => _keyValues.Count;
+
+ public IEnumerator> GetEnumerator()
+ {
+ var count = _keyValues.Count;
+ for (var i = 0; i < count; i++)
+ {
+ yield return _keyValues[i];
+ }
+ }
+
+ public override string ToString()
+ {
+ if (_cachedToString == null)
+ {
+ // TODO use string.Create instead of a StringBuilder here.
+ var builder = new StringBuilder();
+ var count = _keyValues.Count;
+ builder.Append("Request:");
+ builder.Append(Environment.NewLine);
+
+ for (var i = 0; i < count - 1; i++)
+ {
+ var kvp = _keyValues[i];
+ builder.Append(kvp.Key);
+ builder.Append(": ");
+ builder.Append(kvp.Value);
+ builder.Append(Environment.NewLine);
+ }
+
+ if (count > 0)
+ {
+ var kvp = _keyValues[count - 1];
+ builder.Append(kvp.Key);
+ builder.Append(": ");
+ builder.Append(kvp.Value);
+ }
+
+ _cachedToString = builder.ToString();
+ }
+
+ return _cachedToString;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/HttpResponseLog.cs b/src/Middleware/HttpLogging/src/HttpResponseLog.cs
new file mode 100644
index 000000000000..a82219f5d143
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/HttpResponseLog.cs
@@ -0,0 +1,73 @@
+// 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;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ internal sealed class HttpResponseLog : IReadOnlyList>
+ {
+ private readonly List> _keyValues;
+ private string? _cachedToString;
+
+ internal static readonly Func Callback = (state, exception) => ((HttpResponseLog)state).ToString();
+
+ public HttpResponseLog(List> keyValues)
+ {
+ _keyValues = keyValues;
+ }
+
+ public KeyValuePair this[int index] => _keyValues[index];
+
+ public int Count => _keyValues.Count;
+
+ public IEnumerator> GetEnumerator()
+ {
+ var count = _keyValues.Count;
+ for (var i = 0; i < count; i++)
+ {
+ yield return _keyValues[i];
+ }
+ }
+
+ public override string ToString()
+ {
+ if (_cachedToString == null)
+ {
+ var builder = new StringBuilder();
+ var count = _keyValues.Count;
+ builder.Append("Response:");
+ builder.Append(Environment.NewLine);
+
+ for (var i = 0; i < count - 1; i++)
+ {
+ var kvp = _keyValues[i];
+ builder.Append(kvp.Key);
+ builder.Append(": ");
+ builder.Append(kvp.Value);
+ builder.Append(Environment.NewLine);
+ }
+
+ if (count > 0)
+ {
+ var kvp = _keyValues[count - 1];
+ builder.Append(kvp.Key);
+ builder.Append(": ");
+ builder.Append(kvp.Value);
+ }
+
+ _cachedToString = builder.ToString();
+ }
+
+ return _cachedToString;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/MediaTypeHelpers.cs b/src/Middleware/HttpLogging/src/MediaTypeHelpers.cs
new file mode 100644
index 000000000000..0537d682286b
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/MediaTypeHelpers.cs
@@ -0,0 +1,70 @@
+// 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.Diagnostics.CodeAnalysis;
+using System.Text;
+using Microsoft.Net.Http.Headers;
+using static Microsoft.AspNetCore.HttpLogging.MediaTypeOptions;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ internal static class MediaTypeHelpers
+ {
+ private static readonly List SupportedEncodings = new List()
+ {
+ Encoding.UTF8,
+ Encoding.Unicode,
+ Encoding.ASCII,
+ Encoding.Latin1 // TODO allowed by default? Make this configurable?
+ };
+
+ public static bool TryGetEncodingForMediaType(string contentType, List mediaTypeList, [NotNullWhen(true)] out Encoding? encoding)
+ {
+ encoding = null;
+ if (mediaTypeList == null || mediaTypeList.Count == 0 || string.IsNullOrEmpty(contentType))
+ {
+ return false;
+ }
+
+ var mediaType = new MediaTypeHeaderValue(contentType);
+
+ if (mediaType.Charset.HasValue)
+ {
+ // Create encoding based on charset
+ var requestEncoding = mediaType.Encoding;
+
+ if (requestEncoding != null)
+ {
+ for (var i = 0; i < SupportedEncodings.Count; i++)
+ {
+ if (string.Equals(requestEncoding.WebName,
+ SupportedEncodings[i].WebName,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ encoding = SupportedEncodings[i];
+ return true;
+ }
+ }
+ }
+ }
+ else
+ {
+ // TODO Binary format https://github.com/dotnet/aspnetcore/issues/31884
+ foreach (var state in mediaTypeList)
+ {
+ var type = state.MediaTypeHeaderValue;
+ if (type.MatchesMediaType(mediaType.MediaType))
+ {
+ // We always set encoding
+ encoding = state.Encoding!;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/MediaTypeOptions.cs b/src/Middleware/HttpLogging/src/MediaTypeOptions.cs
new file mode 100644
index 000000000000..beb5314ef2d4
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/MediaTypeOptions.cs
@@ -0,0 +1,127 @@
+// 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.Text;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ ///
+ /// Options for HttpLogging to configure which encoding to use for each media type.
+ ///
+ public sealed class MediaTypeOptions
+ {
+ private readonly List _mediaTypeStates = new();
+
+ internal MediaTypeOptions()
+ {
+ }
+
+ internal List MediaTypeStates => _mediaTypeStates;
+
+ internal static MediaTypeOptions BuildDefaultMediaTypeOptions()
+ {
+ var options = new MediaTypeOptions();
+ options.AddText("application/json", Encoding.UTF8);
+ options.AddText("application/*+json", Encoding.UTF8);
+ options.AddText("application/xml", Encoding.UTF8);
+ options.AddText("application/*+xml", Encoding.UTF8);
+ options.AddText("text/*", Encoding.UTF8);
+
+ return options;
+ }
+
+ internal void AddText(MediaTypeHeaderValue mediaType)
+ {
+ if (mediaType == null)
+ {
+ throw new ArgumentNullException(nameof(mediaType));
+ }
+
+ mediaType.Encoding ??= Encoding.UTF8;
+
+ _mediaTypeStates.Add(new MediaTypeState(mediaType) { Encoding = mediaType.Encoding });
+ }
+
+ ///
+ /// Adds a contentType to be used for logging as text.
+ ///
+ ///
+ /// If charset is not specified in the contentType, the encoding will default to UTF-8.
+ ///
+ /// The content type to add.
+ public void AddText(string contentType)
+ {
+ if (contentType == null)
+ {
+ throw new ArgumentNullException(nameof(contentType));
+ }
+
+ AddText(MediaTypeHeaderValue.Parse(contentType));
+ }
+
+ ///
+ /// Adds a contentType to be used for logging as text.
+ ///
+ /// The content type to add.
+ /// The encoding to use.
+ public void AddText(string contentType, Encoding encoding)
+ {
+ if (contentType == null)
+ {
+ throw new ArgumentNullException(nameof(contentType));
+ }
+
+ if (encoding == null)
+ {
+ throw new ArgumentNullException(nameof(encoding));
+ }
+
+ var mediaType = MediaTypeHeaderValue.Parse(contentType);
+ mediaType.Encoding = encoding;
+ AddText(mediaType);
+ }
+
+ ///
+ /// Adds a to be used for logging as binary.
+ ///
+ /// The MediaType to add.
+ public void AddBinary(MediaTypeHeaderValue mediaType)
+ {
+ throw new NotSupportedException();
+ }
+
+ ///
+ /// Adds a content to be used for logging as text.
+ ///
+ /// The content type to add.
+ public void AddBinary(string contentType)
+ {
+ throw new NotSupportedException();
+ }
+
+ ///
+ /// Clears all MediaTypes.
+ ///
+ public void Clear()
+ {
+ _mediaTypeStates.Clear();
+ }
+
+ internal readonly struct MediaTypeState
+ {
+ public MediaTypeState(MediaTypeHeaderValue mediaTypeHeaderValue)
+ {
+ MediaTypeHeaderValue = mediaTypeHeaderValue;
+ Encoding = null;
+ IsBinary = false;
+ }
+
+ public MediaTypeHeaderValue MediaTypeHeaderValue { get; }
+ public Encoding? Encoding { get; init; }
+ public bool IsBinary { get; init; }
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/Microsoft.AspNetCore.HttpLogging.csproj b/src/Middleware/HttpLogging/src/Microsoft.AspNetCore.HttpLogging.csproj
new file mode 100644
index 000000000000..836949427bf6
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/Microsoft.AspNetCore.HttpLogging.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+ ASP.NET Core middleware for logging HTTP requests and responses.
+
+ $(DefaultNetCoreTargetFramework)
+ true
+ true
+ false
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Middleware/HttpLogging/src/Properties/AssemblyInfo.cs b/src/Middleware/HttpLogging/src/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000000..62d2d373c4b8
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Microsoft.AspNetCore.HttpLogging.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
diff --git a/src/Middleware/HttpLogging/src/PublicAPI.Shipped.txt b/src/Middleware/HttpLogging/src/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/Middleware/HttpLogging/src/PublicAPI.Unshipped.txt b/src/Middleware/HttpLogging/src/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..8295c237914a
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/PublicAPI.Unshipped.txt
@@ -0,0 +1,42 @@
+#nullable enable
+Microsoft.AspNetCore.Builder.HttpLoggingBuilderExtensions
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.Request | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.Response -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.None = 0 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.Request = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestPropertiesAndHeaders | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestBody -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestBody = 1024 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestHeaders = 64 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestMethod = 8 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestPath = 1 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestProperties = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestPath | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestQuery | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestProtocol | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestMethod | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestScheme -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestPropertiesAndHeaders = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestProperties | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestHeaders -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestProtocol = 4 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestQuery = 2 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestScheme = 16 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.RequestTrailers = 256 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.Response = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponsePropertiesAndHeaders | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponseBody -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponseBody = 2048 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponseHeaders = 128 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponsePropertiesAndHeaders = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponseStatusCode | Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponseHeaders -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponseStatusCode = 32 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.ResponseTrailers = 512 -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.HttpLoggingOptions() -> void
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.LoggingFields.get -> Microsoft.AspNetCore.HttpLogging.HttpLoggingFields
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.LoggingFields.set -> void
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.MediaTypeOptions.get -> Microsoft.AspNetCore.HttpLogging.MediaTypeOptions!
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.RequestBodyLogLimit.get -> int
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.RequestBodyLogLimit.set -> void
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.RequestHeaders.get -> System.Collections.Generic.ISet!
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.ResponseBodyLogLimit.get -> int
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.ResponseBodyLogLimit.set -> void
+Microsoft.AspNetCore.HttpLogging.HttpLoggingOptions.ResponseHeaders.get -> System.Collections.Generic.ISet!
+Microsoft.AspNetCore.HttpLogging.MediaTypeOptions
+Microsoft.AspNetCore.HttpLogging.MediaTypeOptions.AddBinary(Microsoft.Net.Http.Headers.MediaTypeHeaderValue! mediaType) -> void
+Microsoft.AspNetCore.HttpLogging.MediaTypeOptions.AddBinary(string! contentType) -> void
+Microsoft.AspNetCore.HttpLogging.MediaTypeOptions.AddText(string! contentType) -> void
+Microsoft.AspNetCore.HttpLogging.MediaTypeOptions.AddText(string! contentType, System.Text.Encoding! encoding) -> void
+Microsoft.AspNetCore.HttpLogging.MediaTypeOptions.Clear() -> void
+Microsoft.Extensions.DependencyInjection.HttpLoggingServicesExtensions
+static Microsoft.AspNetCore.Builder.HttpLoggingBuilderExtensions.UseHttpLogging(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder!
+static Microsoft.Extensions.DependencyInjection.HttpLoggingServicesExtensions.AddHttpLogging(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/Middleware/HttpLogging/src/RequestBufferingStream.cs b/src/Middleware/HttpLogging/src/RequestBufferingStream.cs
new file mode 100644
index 000000000000..c9ef64584c92
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/RequestBufferingStream.cs
@@ -0,0 +1,116 @@
+// 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.Buffers;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Pipelines;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ internal sealed class RequestBufferingStream : BufferingStream
+ {
+ private Encoding _encoding;
+ private readonly int _limit;
+
+ public bool HasLogged { get; private set; }
+
+ public RequestBufferingStream(Stream innerStream, int limit, ILogger logger, Encoding encoding)
+ : base(innerStream, logger)
+ {
+ _logger = logger;
+ _limit = limit;
+ _innerStream = innerStream;
+ _encoding = encoding;
+ }
+
+ public override async ValueTask ReadAsync(Memory destination, CancellationToken cancellationToken = default)
+ {
+ var res = await _innerStream.ReadAsync(destination, cancellationToken);
+
+ WriteToBuffer(destination.Slice(0, res).Span);
+
+ return res;
+ }
+
+ public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ var res = await _innerStream.ReadAsync(buffer, offset, count, cancellationToken);
+
+ WriteToBuffer(buffer.AsSpan(offset, res));
+
+ return res;
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ var res = _innerStream.Read(buffer, offset, count);
+
+ WriteToBuffer(buffer.AsSpan(offset, res));
+
+ return res;
+ }
+
+ private void WriteToBuffer(ReadOnlySpan span)
+ {
+ // get what was read into the buffer
+ var remaining = _limit - _bytesBuffered;
+
+ if (remaining == 0)
+ {
+ return;
+ }
+
+ if (span.Length == 0 && !HasLogged)
+ {
+ // Done reading, log the string.
+ LogRequestBody();
+ return;
+ }
+
+ var innerCount = Math.Min(remaining, span.Length);
+
+ if (span.Slice(0, innerCount).TryCopyTo(_tailMemory.Span))
+ {
+ _tailBytesBuffered += innerCount;
+ _bytesBuffered += innerCount;
+ _tailMemory = _tailMemory.Slice(innerCount);
+ }
+ else
+ {
+ BuffersExtensions.Write(this, span.Slice(0, innerCount));
+ }
+
+ if (_limit - _bytesBuffered == 0 && !HasLogged)
+ {
+ LogRequestBody();
+ }
+ }
+
+ public void LogRequestBody()
+ {
+ var requestBody = GetString(_encoding);
+ if (requestBody != null)
+ {
+ _logger.RequestBody(requestBody);
+ }
+ HasLogged = true;
+ }
+
+ public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)
+ {
+ return TaskToApm.Begin(ReadAsync(buffer, offset, count), callback, state);
+ }
+
+ public override int EndRead(IAsyncResult asyncResult)
+ {
+ return TaskToApm.End(asyncResult);
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/src/ResponseBufferingStream.cs b/src/Middleware/HttpLogging/src/ResponseBufferingStream.cs
new file mode 100644
index 000000000000..3c889b92ce1d
--- /dev/null
+++ b/src/Middleware/HttpLogging/src/ResponseBufferingStream.cs
@@ -0,0 +1,177 @@
+// 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.Buffers;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Pipelines;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+using static Microsoft.AspNetCore.HttpLogging.MediaTypeOptions;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ ///
+ /// Stream that buffers reads
+ ///
+ internal sealed class ResponseBufferingStream : BufferingStream, IHttpResponseBodyFeature
+ {
+ private readonly IHttpResponseBodyFeature _innerBodyFeature;
+ private readonly int _limit;
+ private PipeWriter? _pipeAdapter;
+
+ private readonly HttpContext _context;
+ private readonly List _encodings;
+ private readonly HttpLoggingOptions _options;
+ private Encoding? _encoding;
+
+ private static readonly StreamPipeWriterOptions _pipeWriterOptions = new StreamPipeWriterOptions(leaveOpen: true);
+
+ internal ResponseBufferingStream(IHttpResponseBodyFeature innerBodyFeature,
+ int limit,
+ ILogger logger,
+ HttpContext context,
+ List encodings,
+ HttpLoggingOptions options)
+ : base(innerBodyFeature.Stream, logger)
+ {
+ _innerBodyFeature = innerBodyFeature;
+ _innerStream = innerBodyFeature.Stream;
+ _limit = limit;
+ _context = context;
+ _encodings = encodings;
+ _options = options;
+ }
+
+ public bool FirstWrite { get; private set; }
+
+ public Stream Stream => this;
+
+ public PipeWriter Writer => _pipeAdapter ??= PipeWriter.Create(Stream, _pipeWriterOptions);
+
+ public Encoding? Encoding { get => _encoding; }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ Write(buffer.AsSpan(offset, count));
+ }
+
+ public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)
+ {
+ return TaskToApm.Begin(WriteAsync(buffer, offset, count), callback, state);
+ }
+
+ public override void EndWrite(IAsyncResult asyncResult)
+ {
+ TaskToApm.End(asyncResult);
+ }
+
+ public override void Write(ReadOnlySpan span)
+ {
+ var remaining = _limit - _bytesBuffered;
+ var innerCount = Math.Min(remaining, span.Length);
+
+ OnFirstWrite();
+
+ if (innerCount > 0)
+ {
+ if (span.Slice(0, innerCount).TryCopyTo(_tailMemory.Span))
+ {
+ _tailBytesBuffered += innerCount;
+ _bytesBuffered += innerCount;
+ _tailMemory = _tailMemory.Slice(innerCount);
+ }
+ else
+ {
+ BuffersExtensions.Write(this, span.Slice(0, innerCount));
+ }
+ }
+
+ _innerStream.Write(span);
+ }
+
+ public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ await WriteAsync(new Memory(buffer, offset, count), cancellationToken);
+ }
+
+ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
+ {
+ var remaining = _limit - _bytesBuffered;
+ var innerCount = Math.Min(remaining, buffer.Length);
+
+ OnFirstWrite();
+
+ if (innerCount > 0)
+ {
+ if (_tailMemory.Length - innerCount > 0)
+ {
+ buffer.Slice(0, innerCount).CopyTo(_tailMemory);
+ _tailBytesBuffered += innerCount;
+ _bytesBuffered += innerCount;
+ _tailMemory = _tailMemory.Slice(innerCount);
+ }
+ else
+ {
+ BuffersExtensions.Write(this, buffer.Span);
+ }
+ }
+
+ await _innerStream.WriteAsync(buffer, cancellationToken);
+ }
+
+ private void OnFirstWrite()
+ {
+ if (!FirstWrite)
+ {
+ // Log headers as first write occurs (headers locked now)
+ HttpLoggingMiddleware.LogResponseHeaders(_context.Response, _options, _logger);
+
+ MediaTypeHelpers.TryGetEncodingForMediaType(_context.Response.ContentType, _encodings, out _encoding);
+ FirstWrite = true;
+ }
+ }
+
+ public void DisableBuffering()
+ {
+ _innerBodyFeature.DisableBuffering();
+ }
+
+ public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation)
+ {
+ OnFirstWrite();
+ return _innerBodyFeature.SendFileAsync(path, offset, count, cancellation);
+ }
+
+ public Task StartAsync(CancellationToken token = default)
+ {
+ OnFirstWrite();
+ return _innerBodyFeature.StartAsync(token);
+ }
+
+ public async Task CompleteAsync()
+ {
+ await _innerBodyFeature.CompleteAsync();
+ }
+
+ public override void Flush()
+ {
+ OnFirstWrite();
+ base.Flush();
+ }
+
+ public override Task FlushAsync(CancellationToken cancellationToken)
+ {
+ OnFirstWrite();
+ return base.FlushAsync(cancellationToken);
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/test/HttpLoggingMiddlewareTests.cs b/src/Middleware/HttpLogging/test/HttpLoggingMiddlewareTests.cs
new file mode 100644
index 000000000000..710e6ad701b4
--- /dev/null
+++ b/src/Middleware/HttpLogging/test/HttpLoggingMiddlewareTests.cs
@@ -0,0 +1,867 @@
+// 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.IO;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.Options;
+using Microsoft.Net.Http.Headers;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ public class HttpLoggingMiddlewareTests : LoggedTest
+ {
+ public static TheoryData BodyData
+ {
+ get
+ {
+ var variations = new TheoryData();
+ variations.Add("Hello World");
+ variations.Add(new string('a', 4097));
+ variations.Add(new string('b', 10000));
+ variations.Add(new string('あ', 10000));
+ return variations;
+ }
+ }
+
+ [Fact]
+ public void Ctor_ThrowsExceptionsWhenNullArgs()
+ {
+ Assert.Throws(() => new HttpLoggingMiddleware(
+ null,
+ CreateOptionsAccessor(),
+ LoggerFactory.CreateLogger()));
+
+ Assert.Throws(() => new HttpLoggingMiddleware(c =>
+ {
+ return Task.CompletedTask;
+ },
+ null,
+ LoggerFactory.CreateLogger()));
+
+ Assert.Throws(() => new HttpLoggingMiddleware(c =>
+ {
+ return Task.CompletedTask;
+ },
+ CreateOptionsAccessor(),
+ null));
+ }
+
+ [Fact]
+ public async Task NoopWhenLoggingDisabled()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.None;
+
+ var middleware = new HttpLoggingMiddleware(
+ c =>
+ {
+ c.Response.StatusCode = 200;
+ return Task.CompletedTask;
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Protocol = "HTTP/1.0";
+ httpContext.Request.Method = "GET";
+ httpContext.Request.Scheme = "http";
+ httpContext.Request.Path = new PathString("/foo");
+ httpContext.Request.PathBase = new PathString("/foo");
+ httpContext.Request.QueryString = new QueryString("?foo");
+ httpContext.Request.Headers["Connection"] = "keep-alive";
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("test"));
+
+ await middleware.Invoke(httpContext);
+
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Protocol: HTTP/1.0"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Method: GET"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Scheme: http"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Path: /foo"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("PathBase: /foo"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("QueryString: ?foo"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Connection: keep-alive"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("StatusCode: 200"));
+ }
+
+ [Fact]
+ public async Task DefaultRequestInfoOnlyHeadersAndRequestInfo()
+ {
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ }
+ },
+ CreateOptionsAccessor(),
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Protocol = "HTTP/1.0";
+ httpContext.Request.Method = "GET";
+ httpContext.Request.Scheme = "http";
+ httpContext.Request.Path = new PathString("/foo");
+ httpContext.Request.PathBase = new PathString("/foo");
+ httpContext.Request.QueryString = new QueryString("?foo");
+ httpContext.Request.Headers["Connection"] = "keep-alive";
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("test"));
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Protocol: HTTP/1.0"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Method: GET"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Scheme: http"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Path: /foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("PathBase: /foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("QueryString: ?foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Connection: keep-alive"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task RequestLogsAllRequestInfo()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.Request;
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ }
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Protocol = "HTTP/1.0";
+ httpContext.Request.Method = "GET";
+ httpContext.Request.Scheme = "http";
+ httpContext.Request.Path = new PathString("/foo");
+ httpContext.Request.PathBase = new PathString("/foo");
+ httpContext.Request.QueryString = new QueryString("?foo");
+ httpContext.Request.Headers["Connection"] = "keep-alive";
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("test"));
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Protocol: HTTP/1.0"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Method: GET"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Scheme: http"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Path: /foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("PathBase: /foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("QueryString: ?foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Connection: keep-alive"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task RequestPropertiesLogs()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestProperties;
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ }
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Protocol = "HTTP/1.0";
+ httpContext.Request.Method = "GET";
+ httpContext.Request.Scheme = "http";
+ httpContext.Request.Path = new PathString("/foo");
+ httpContext.Request.PathBase = new PathString("/foo");
+ httpContext.Request.QueryString = new QueryString("?foo");
+ httpContext.Request.Headers["Connection"] = "keep-alive";
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("test"));
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Protocol: HTTP/1.0"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Method: GET"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Scheme: http"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Path: /foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("PathBase: /foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("QueryString: ?foo"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Connection: keep-alive"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task RequestHeadersLogs()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestHeaders;
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ }
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Protocol = "HTTP/1.0";
+ httpContext.Request.Method = "GET";
+ httpContext.Request.Scheme = "http";
+ httpContext.Request.Path = new PathString("/foo");
+ httpContext.Request.PathBase = new PathString("/foo");
+ httpContext.Request.QueryString = new QueryString("?foo");
+ httpContext.Request.Headers["Connection"] = "keep-alive";
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("test"));
+
+ await middleware.Invoke(httpContext);
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Protocol: HTTP/1.0"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Method: GET"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Scheme: http"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Path: /foo"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("PathBase: /foo"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("QueryString: ?foo"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Connection: keep-alive"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task UnknownRequestHeadersRedacted()
+ {
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ }
+ },
+ CreateOptionsAccessor(),
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ httpContext.Request.Headers["foo"] = "bar";
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("foo: [Redacted]"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("foo: bar"));
+ }
+
+ [Fact]
+ public async Task CanConfigureRequestAllowList()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.RequestHeaders.Clear();
+ options.CurrentValue.RequestHeaders.Add("foo");
+ var middleware = new HttpLoggingMiddleware(
+ c =>
+ {
+ return Task.CompletedTask;
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ // Header on the default allow list.
+ httpContext.Request.Headers["Connection"] = "keep-alive";
+
+ httpContext.Request.Headers["foo"] = "bar";
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("foo: bar"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("foo: [Redacted]"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Connection: [Redacted]"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Connection: keep-alive"));
+ }
+
+ [Theory]
+ [MemberData(nameof(BodyData))]
+ public async Task RequestBodyReadingWorks(string expected)
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestBody;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ }
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(expected));
+
+ await middleware.Invoke(httpContext);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains(expected));
+ }
+
+ [Fact]
+ public async Task RequestBodyReadingLimitLongCharactersWorks()
+ {
+ var input = string.Concat(new string('あ', 5));
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestBody;
+ options.CurrentValue.RequestBodyLogLimit = 4;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ var count = 0;
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ count += res;
+ }
+
+ Assert.Equal(15, count);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(input));
+
+ await middleware.Invoke(httpContext);
+ var expected = input.Substring(0, options.CurrentValue.RequestBodyLogLimit / 3);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Equals("RequestBody: " + expected));
+ }
+
+ [Fact]
+ public async Task RequestBodyReadingLimitWorks()
+ {
+ var input = string.Concat(new string('a', 60000), new string('b', 3000));
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestBody;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ var count = 0;
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ count += res;
+ }
+
+ Assert.Equal(63000, count);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(input));
+
+ await middleware.Invoke(httpContext);
+ var expected = input.Substring(0, options.CurrentValue.ResponseBodyLogLimit);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Equals("RequestBody: " + expected));
+ }
+
+ [Fact]
+ public async Task PartialReadBodyStillLogs()
+ {
+ var input = string.Concat(new string('a', 60000), new string('b', 3000));
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestBody;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ var res = await c.Request.Body.ReadAsync(arr);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(input));
+
+ await middleware.Invoke(httpContext);
+ var expected = input.Substring(0, 4096);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Equals("RequestBody: " + expected));
+ }
+
+ [Theory]
+ [InlineData("text/plain")]
+ [InlineData("text/html")]
+ [InlineData("application/json")]
+ [InlineData("application/xml")]
+ [InlineData("application/entity+json")]
+ [InlineData("application/entity+xml")]
+ public async Task VerifyDefaultMediaTypeHeaders(string contentType)
+ {
+ // media headers that should work.
+ var expected = new string('a', 1000);
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestBody;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ }
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.ContentType = contentType;
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(expected));
+
+ await middleware.Invoke(httpContext);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains(expected));
+ }
+
+ [Theory]
+ [InlineData("application/invalid")]
+ [InlineData("multipart/form-data")]
+ public async Task RejectedContentTypes(string contentType)
+ {
+ // media headers that should work.
+ var expected = new string('a', 1000);
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestBody;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ var count = 0;
+
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ count += res;
+ }
+
+ Assert.Equal(1000, count);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.ContentType = contentType;
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(expected));
+
+ await middleware.Invoke(httpContext);
+
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains(expected));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Unrecognized Content-Type for body."));
+ }
+
+ [Fact]
+ public async Task DifferentEncodingsWork()
+ {
+ var encoding = Encoding.Unicode;
+ var expected = new string('a', 1000);
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.RequestBody;
+ options.CurrentValue.MediaTypeOptions.Clear();
+ options.CurrentValue.MediaTypeOptions.AddText("text/plain", encoding);
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ var arr = new byte[4096];
+ var count = 0;
+ while (true)
+ {
+ var res = await c.Request.Body.ReadAsync(arr);
+ if (res == 0)
+ {
+ break;
+ }
+ count += res;
+ }
+
+ Assert.Equal(2000, count);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.ContentType = "text/plain";
+ httpContext.Request.Body = new MemoryStream(encoding.GetBytes(expected));
+
+ await middleware.Invoke(httpContext);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains(expected));
+ }
+
+ [Fact]
+ public async Task DefaultResponseInfoOnlyHeadersAndRequestInfo()
+ {
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ c.Response.StatusCode = 200;
+ c.Response.Headers[HeaderNames.TransferEncoding] = "test";
+ c.Response.ContentType = "text/plain";
+ await c.Response.WriteAsync("test");
+ },
+ CreateOptionsAccessor(),
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("StatusCode: 200"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Transfer-Encoding: test"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task ResponseInfoLogsAll()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.Response;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ c.Response.StatusCode = 200;
+ c.Response.Headers[HeaderNames.TransferEncoding] = "test";
+ c.Response.ContentType = "text/plain";
+ await c.Response.WriteAsync("test");
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("StatusCode: 200"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Transfer-Encoding: test"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+
+ [Fact]
+ public async Task StatusCodeLogs()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.ResponseStatusCode;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ c.Response.StatusCode = 200;
+ c.Response.Headers["Server"] = "Kestrel";
+ c.Response.ContentType = "text/plain";
+ await c.Response.WriteAsync("test");
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("StatusCode: 200"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Server: Kestrel"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task ResponseHeadersLogs()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.ResponseHeaders;
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ c.Response.StatusCode = 200;
+ c.Response.Headers[HeaderNames.TransferEncoding] = "test";
+ c.Response.ContentType = "text/plain";
+ await c.Response.WriteAsync("test");
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("StatusCode: 200"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Transfer-Encoding: test"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task ResponseHeadersRedacted()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.ResponseHeaders;
+
+ var middleware = new HttpLoggingMiddleware(
+ c =>
+ {
+ c.Response.Headers["Test"] = "Kestrel";
+ return Task.CompletedTask;
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Test: [Redacted]"));
+ }
+
+ [Fact]
+ public async Task AllowedResponseHeadersModify()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.ResponseHeaders;
+ options.CurrentValue.ResponseHeaders.Clear();
+ options.CurrentValue.ResponseHeaders.Add("Test");
+
+ var middleware = new HttpLoggingMiddleware(
+ c =>
+ {
+ c.Response.Headers["Test"] = "Kestrel";
+ c.Response.Headers["Server"] = "Kestrel";
+ return Task.CompletedTask;
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Test: Kestrel"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Server: [Redacted]"));
+ }
+
+ [Theory]
+ [MemberData(nameof(BodyData))]
+ public async Task ResponseBodyWritingWorks(string expected)
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.ResponseBody;
+ var middleware = new HttpLoggingMiddleware(
+ c =>
+ {
+ c.Response.ContentType = "text/plain";
+ return c.Response.WriteAsync(expected);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains(expected));
+ }
+
+ [Fact]
+ public async Task ResponseBodyWritingLimitWorks()
+ {
+ var input = string.Concat(new string('a', 30000), new string('b', 3000));
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.ResponseBody;
+ var middleware = new HttpLoggingMiddleware(
+ c =>
+ {
+ c.Response.ContentType = "text/plain";
+ return c.Response.WriteAsync(input);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+
+ var expected = input.Substring(0, options.CurrentValue.ResponseBodyLogLimit);
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains(expected));
+ }
+
+ [Fact]
+ public async Task FirstWriteResponseHeadersLogged()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.Response;
+
+ var writtenHeaders = new TaskCompletionSource();
+ var letBodyFinish = new TaskCompletionSource();
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ c.Response.StatusCode = 200;
+ c.Response.Headers[HeaderNames.TransferEncoding] = "test";
+ c.Response.ContentType = "text/plain";
+ await c.Response.WriteAsync("test");
+ writtenHeaders.SetResult(null);
+ await letBodyFinish.Task;
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ var middlewareTask = middleware.Invoke(httpContext);
+
+ await writtenHeaders.Task;
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("StatusCode: 200"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Transfer-Encoding: test"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+
+ letBodyFinish.SetResult(null);
+
+ await middlewareTask;
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Body: test"));
+ }
+
+ [Fact]
+ public async Task StartAsyncResponseHeadersLogged()
+ {
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.Response;
+
+ var writtenHeaders = new TaskCompletionSource();
+ var letBodyFinish = new TaskCompletionSource();
+
+ var middleware = new HttpLoggingMiddleware(
+ async c =>
+ {
+ c.Response.StatusCode = 200;
+ c.Response.Headers[HeaderNames.TransferEncoding] = "test";
+ c.Response.ContentType = "text/plain";
+ await c.Response.StartAsync();
+ writtenHeaders.SetResult(null);
+ await letBodyFinish.Task;
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ var middlewareTask = middleware.Invoke(httpContext);
+
+ await writtenHeaders.Task;
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("StatusCode: 200"));
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Transfer-Encoding: test"));
+ Assert.DoesNotContain(TestSink.Writes, w => w.Message.Contains("Body: test"));
+
+ letBodyFinish.SetResult(null);
+
+ await middlewareTask;
+ }
+
+ [Fact]
+ public async Task UnrecognizedMediaType()
+ {
+ var expected = "Hello world";
+ var options = CreateOptionsAccessor();
+ options.CurrentValue.LoggingFields = HttpLoggingFields.ResponseBody;
+ var middleware = new HttpLoggingMiddleware(
+ c =>
+ {
+ c.Response.ContentType = "foo/*";
+ return c.Response.WriteAsync(expected);
+ },
+ options,
+ LoggerFactory.CreateLogger());
+
+ var httpContext = new DefaultHttpContext();
+
+ await middleware.Invoke(httpContext);
+
+ Assert.Contains(TestSink.Writes, w => w.Message.Contains("Unrecognized Content-Type for body."));
+ }
+
+ private IOptionsMonitor CreateOptionsAccessor()
+ {
+ var options = new HttpLoggingOptions();
+ var optionsAccessor = Mock.Of>(o => o.CurrentValue == options);
+ return optionsAccessor;
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/test/HttpLoggingOptionsTests.cs b/src/Middleware/HttpLogging/test/HttpLoggingOptionsTests.cs
new file mode 100644
index 000000000000..d91f23d2440b
--- /dev/null
+++ b/src/Middleware/HttpLogging/test/HttpLoggingOptionsTests.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 Xunit;
+
+namespace Microsoft.AspNetCore.HttpLogging
+{
+ public class HttpLoggingOptionsTests
+ {
+ [Fact]
+ public void DefaultsMediaTypes()
+ {
+ var options = new HttpLoggingOptions();
+ var defaultMediaTypes = options.MediaTypeOptions.MediaTypeStates;
+ Assert.Equal(5, defaultMediaTypes.Count);
+
+ Assert.Contains(defaultMediaTypes, w => w.MediaTypeHeaderValue.MediaType.Equals("application/json"));
+ Assert.Contains(defaultMediaTypes, w => w.MediaTypeHeaderValue.MediaType.Equals("application/*+json"));
+ Assert.Contains(defaultMediaTypes, w => w.MediaTypeHeaderValue.MediaType.Equals("application/xml"));
+ Assert.Contains(defaultMediaTypes, w => w.MediaTypeHeaderValue.MediaType.Equals("application/*+xml"));
+ Assert.Contains(defaultMediaTypes, w => w.MediaTypeHeaderValue.MediaType.Equals("text/*"));
+ }
+
+ [Fact]
+ public void CanAddMediaTypesString()
+ {
+ var options = new HttpLoggingOptions();
+ options.MediaTypeOptions.AddText("test/*");
+
+ var defaultMediaTypes = options.MediaTypeOptions.MediaTypeStates;
+ Assert.Equal(6, defaultMediaTypes.Count);
+
+ Assert.Contains(defaultMediaTypes, w => w.MediaTypeHeaderValue.MediaType.Equals("test/*"));
+ }
+
+ [Fact]
+ public void CanAddMediaTypesWithCharset()
+ {
+ var options = new HttpLoggingOptions();
+ options.MediaTypeOptions.AddText("test/*; charset=ascii");
+
+ var defaultMediaTypes = options.MediaTypeOptions.MediaTypeStates;
+ Assert.Equal(6, defaultMediaTypes.Count);
+
+ Assert.Contains(defaultMediaTypes, w => w.MediaTypeHeaderValue.Encoding.WebName.Equals("us-ascii"));
+ }
+
+ [Fact]
+ public void CanClearMediaTypes()
+ {
+ var options = new HttpLoggingOptions();
+ options.MediaTypeOptions.Clear();
+ Assert.Empty(options.MediaTypeOptions.MediaTypeStates);
+ }
+
+ [Fact]
+ public void HeadersAreCaseInsensitive()
+ {
+ var options = new HttpLoggingOptions();
+ options.RequestHeaders.Clear();
+ options.RequestHeaders.Add("Test");
+ options.RequestHeaders.Add("test");
+
+ Assert.Single(options.RequestHeaders);
+ }
+ }
+}
diff --git a/src/Middleware/HttpLogging/test/Microsoft.AspNetCore.HttpLogging.Tests.csproj b/src/Middleware/HttpLogging/test/Microsoft.AspNetCore.HttpLogging.Tests.csproj
new file mode 100644
index 000000000000..17c52be2ca50
--- /dev/null
+++ b/src/Middleware/HttpLogging/test/Microsoft.AspNetCore.HttpLogging.Tests.csproj
@@ -0,0 +1,12 @@
+
+
+
+ $(DefaultNetCoreTargetFramework)
+
+
+
+
+
+
+
+
diff --git a/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs b/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs
index 0e1c625bd7dc..632b40f709d5 100644
--- a/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs
+++ b/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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 Microsoft.AspNetCore.Http;
diff --git a/src/Middleware/Middleware.slnf b/src/Middleware/Middleware.slnf
index a670000801b9..f4f2843ee531 100644
--- a/src/Middleware/Middleware.slnf
+++ b/src/Middleware/Middleware.slnf
@@ -31,6 +31,9 @@
"src\\Middleware\\HostFiltering\\sample\\HostFilteringSample.csproj",
"src\\Middleware\\HostFiltering\\src\\Microsoft.AspNetCore.HostFiltering.csproj",
"src\\Middleware\\HostFiltering\\test\\Microsoft.AspNetCore.HostFiltering.Tests.csproj",
+ "src\\Middleware\\HttpLogging\\samples\\HttpLogging.Sample\\HttpLogging.Sample.csproj",
+ "src\\Middleware\\HttpLogging\\src\\Microsoft.AspNetCore.HttpLogging.csproj",
+ "src\\Middleware\\HttpLogging\\test\\Microsoft.AspNetCore.HttpLogging.Tests.csproj",
"src\\Middleware\\HttpOverrides\\sample\\HttpOverridesSample.csproj",
"src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj",
"src\\Middleware\\HttpOverrides\\test\\Microsoft.AspNetCore.HttpOverrides.Tests.csproj",
diff --git a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
index ef7726f15f68..a9a6fe7986c3 100644
--- a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
+++ b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
@@ -18,6 +18,7 @@
+
diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/BufferSegment.cs b/src/Shared/Buffers/BufferSegment.cs
similarity index 100%
rename from src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/BufferSegment.cs
rename to src/Shared/Buffers/BufferSegment.cs
diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/BufferSegmentStack.cs b/src/Shared/Buffers/BufferSegmentStack.cs
similarity index 100%
rename from src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/BufferSegmentStack.cs
rename to src/Shared/Buffers/BufferSegmentStack.cs