diff --git a/AspNetCore.sln b/AspNetCore.sln index 81801038c953..9e5ec5932ba1 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1626,6 +1626,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.SpaSer EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinimalSample", "src\Http\samples\MinimalSample\MinimalSample.csproj", "{9647D8B7-4616-4E05-B258-BAD5CAEEDD38}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HttpLogging", "HttpLogging", "{022B4B80-E813-4256-8034-11A68146F4EF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HttpLogging", "src\Middleware\HttpLogging\src\Microsoft.AspNetCore.HttpLogging.csproj", "{FF413F1C-A998-4FA2-823F-52AC0916B35C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HttpLogging.Tests", "src\Middleware\HttpLogging\test\Microsoft.AspNetCore.HttpLogging.Tests.csproj", "{3A1EC883-EF9C-43E8-95E5-6B527428867B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpLogging.Sample", "src\Middleware\HttpLogging\samples\HttpLogging.Sample\HttpLogging.Sample.csproj", "{908B2263-B58B-4261-A125-B5F2DFF92799}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -7709,6 +7717,42 @@ Global {9647D8B7-4616-4E05-B258-BAD5CAEEDD38}.Release|x64.Build.0 = Release|Any CPU {9647D8B7-4616-4E05-B258-BAD5CAEEDD38}.Release|x86.ActiveCfg = Release|Any CPU {9647D8B7-4616-4E05-B258-BAD5CAEEDD38}.Release|x86.Build.0 = Release|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Debug|x64.Build.0 = Debug|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Debug|x86.Build.0 = Debug|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Release|Any CPU.Build.0 = Release|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Release|x64.ActiveCfg = Release|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Release|x64.Build.0 = Release|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Release|x86.ActiveCfg = Release|Any CPU + {FF413F1C-A998-4FA2-823F-52AC0916B35C}.Release|x86.Build.0 = Release|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Debug|x64.Build.0 = Debug|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Debug|x86.Build.0 = Debug|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Release|Any CPU.Build.0 = Release|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Release|x64.ActiveCfg = Release|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Release|x64.Build.0 = Release|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Release|x86.ActiveCfg = Release|Any CPU + {3A1EC883-EF9C-43E8-95E5-6B527428867B}.Release|x86.Build.0 = Release|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Debug|Any CPU.Build.0 = Debug|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Debug|x64.ActiveCfg = Debug|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Debug|x64.Build.0 = Debug|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Debug|x86.ActiveCfg = Debug|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Debug|x86.Build.0 = Debug|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Release|Any CPU.ActiveCfg = Release|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Release|Any CPU.Build.0 = Release|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Release|x64.ActiveCfg = Release|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Release|x64.Build.0 = Release|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Release|x86.ActiveCfg = Release|Any CPU + {908B2263-B58B-4261-A125-B5F2DFF92799}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -8514,6 +8558,10 @@ Global {DF4637DA-5F07-4903-8461-4E2DAB235F3C} = {7F99E967-3DC1-4198-9D55-47CD9471D0B6} {AAB50C64-39AA-4AED-8E9C-50D68E7751AD} = {7F99E967-3DC1-4198-9D55-47CD9471D0B6} {9647D8B7-4616-4E05-B258-BAD5CAEEDD38} = {EB5E294B-9ED5-43BF-AFA9-1CD2327F3DC1} + {022B4B80-E813-4256-8034-11A68146F4EF} = {E5963C9F-20A6-4385-B364-814D2581FADF} + {FF413F1C-A998-4FA2-823F-52AC0916B35C} = {022B4B80-E813-4256-8034-11A68146F4EF} + {3A1EC883-EF9C-43E8-95E5-6B527428867B} = {022B4B80-E813-4256-8034-11A68146F4EF} + {908B2263-B58B-4261-A125-B5F2DFF92799} = {022B4B80-E813-4256-8034-11A68146F4EF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index a0a18b9d018a..d42d0b35990a 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -83,6 +83,7 @@ + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index 49d28f78f0f2..e29a6f2a2d30 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -69,6 +69,7 @@ + diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index cf2d397e67b9..6df04b54e731 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -55,6 +55,7 @@ static TestData() "Microsoft.AspNetCore.Http.Connections.Common", "Microsoft.AspNetCore.Http.Extensions", "Microsoft.AspNetCore.Http.Features", + "Microsoft.AspNetCore.HttpLogging", "Microsoft.AspNetCore.HttpOverrides", "Microsoft.AspNetCore.HttpsPolicy", "Microsoft.AspNetCore.Identity", @@ -186,6 +187,7 @@ static TestData() { "Microsoft.AspNetCore.Http.Connections.Common", "6.0.0.0" }, { "Microsoft.AspNetCore.Http.Extensions", "6.0.0.0" }, { "Microsoft.AspNetCore.Http.Features", "6.0.0.0" }, + { "Microsoft.AspNetCore.HttpLogging", "6.0.0.0" }, { "Microsoft.AspNetCore.HttpOverrides", "6.0.0.0" }, { "Microsoft.AspNetCore.HttpsPolicy", "6.0.0.0" }, { "Microsoft.AspNetCore.Identity", "6.0.0.0" }, diff --git a/src/Middleware/HttpLogging/samples/HttpLogging.Sample/HttpLogging.Sample.csproj b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/HttpLogging.Sample.csproj new file mode 100644 index 000000000000..21b5a022f5a1 --- /dev/null +++ b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/HttpLogging.Sample.csproj @@ -0,0 +1,16 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + + diff --git a/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Program.cs b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Program.cs new file mode 100644 index 000000000000..2f43518e138f --- /dev/null +++ b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Program.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace HttpLogging.Sample +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => + { + // Json Logging + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.JsonWriterOptions = new JsonWriterOptions() + { + Indented = true + }; + }); + }) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Properties/launchSettings.json b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Properties/launchSettings.json new file mode 100644 index 000000000000..a5d93ea2eca4 --- /dev/null +++ b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:63240", + "sslPort": 0 + } + }, + "profiles": { + "HttpLogging.Sample": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Startup.cs b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Startup.cs new file mode 100644 index 000000000000..a85a95cd1b58 --- /dev/null +++ b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/Startup.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpLogging; +using Microsoft.Extensions.DependencyInjection; + +namespace HttpLogging.Sample +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + services.AddHttpLogging(logging => + { + logging.LoggingFields = HttpLoggingFields.All; + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseHttpLogging(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.Map("/", async context => + { + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync("Hello World!"); + }); + }); + } + } +} diff --git a/src/Middleware/HttpLogging/samples/HttpLogging.Sample/appsettings.Development.json b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/appsettings.Development.json new file mode 100644 index 000000000000..63ed6e14613c --- /dev/null +++ b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Middleware/HttpLogging/samples/HttpLogging.Sample/appsettings.json b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/appsettings.json new file mode 100644 index 000000000000..214d63f9b192 --- /dev/null +++ b/src/Middleware/HttpLogging/samples/HttpLogging.Sample/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Middleware/HttpLogging/src/BufferingStream.cs b/src/Middleware/HttpLogging/src/BufferingStream.cs new file mode 100644 index 000000000000..90ee3b01a7d8 --- /dev/null +++ b/src/Middleware/HttpLogging/src/BufferingStream.cs @@ -0,0 +1,326 @@ +// 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 abstract class BufferingStream : Stream, IBufferWriter + { + private const int MinimumBufferSize = 4096; // 4K + protected int _bytesBuffered; + private BufferSegment? _head; + private BufferSegment? _tail; + protected Memory _tailMemory; // remainder of tail memory + protected int _tailBytesBuffered; + protected ILogger _logger; + protected Stream _innerStream; + + public BufferingStream(Stream innerStream, ILogger logger) + { + _logger = logger; + _innerStream = innerStream; + } + + public override bool CanSeek => _innerStream.CanSeek; + + public override bool CanRead => _innerStream.CanRead; + + public override bool CanWrite => _innerStream.CanWrite; + + public override long Length => _innerStream.Length; + + public override long Position + { + get => _innerStream.Position; + set => _innerStream.Position = value; + } + + public override int WriteTimeout + { + get => _innerStream.WriteTimeout; + set => _innerStream.WriteTimeout = value; + } + + public string GetString(Encoding? encoding) + { + try + { + if (_head == null || _tail == null) + { + // nothing written + return ""; + } + + if (encoding == null) + { + _logger.UnrecognizedMediaType(); + return ""; + } + + // Only place where we are actually using the buffered data. + // update tail here. + _tail.End = _tailBytesBuffered; + + var ros = new ReadOnlySequence(_head, 0, _tail, _tailBytesBuffered); + + var bufferWriter = new ArrayBufferWriter(); + + var decoder = encoding.GetDecoder(); + // First calls convert on the entire ReadOnlySequence, with flush: false. + // flush: false is required as we don't want to write invalid characters that + // are spliced due to truncation. If we set flush: true, if effectively means + // we expect EOF in this array, meaning it will try to write any bytes at the end of it. + EncodingExtensions.Convert(decoder, ros, bufferWriter, flush: false, out var charUsed, out var completed); + + // Afterwards, we need to call convert in a loop until complete is true. + // The first call to convert many return true, but if it doesn't, we call + // Convert with a empty ReadOnlySequence and flush: true until we get completed: true. + + // This should never infinite due to the contract for decoders. + // But for safety, call this only 10 times, throwing a decode failure if it fails. + for (var i = 0; i < 10; i++) + { + if (completed) + { + return new string(bufferWriter.WrittenSpan); + } + else + { + EncodingExtensions.Convert(decoder, ReadOnlySequence.Empty, bufferWriter, flush: true, out charUsed, out completed); + } + } + + throw new DecoderFallbackException("Failed to decode after 10 calls to Decoder.Convert"); + } + catch (DecoderFallbackException ex) + { + _logger.DecodeFailure(ex); + return ""; + } + finally + { + Reset(); + } + } + + public void Advance(int bytes) + { + if ((uint)bytes > (uint)_tailMemory.Length) + { + ThrowArgumentOutOfRangeException(nameof(bytes)); + } + + _tailBytesBuffered += bytes; + _bytesBuffered += bytes; + _tailMemory = _tailMemory.Slice(bytes); + } + + public Memory GetMemory(int sizeHint = 0) + { + AllocateMemory(sizeHint); + return _tailMemory; + } + + public Span GetSpan(int sizeHint = 0) + { + AllocateMemory(sizeHint); + return _tailMemory.Span; + } + + private void AllocateMemory(int sizeHint) + { + if (_head is null) + { + // We need to allocate memory to write since nobody has written before + var newSegment = AllocateSegment(sizeHint); + + // Set all the pointers + _head = _tail = newSegment; + _tailBytesBuffered = 0; + } + else + { + var bytesLeftInBuffer = _tailMemory.Length; + + if (bytesLeftInBuffer == 0 || bytesLeftInBuffer < sizeHint) + { + Debug.Assert(_tail != null); + + if (_tailBytesBuffered > 0) + { + // Flush buffered data to the segment + _tail.End += _tailBytesBuffered; + _tailBytesBuffered = 0; + } + + var newSegment = AllocateSegment(sizeHint); + + _tail.SetNext(newSegment); + _tail = newSegment; + } + } + } + + private BufferSegment AllocateSegment(int sizeHint) + { + var newSegment = CreateSegment(); + + // We can't use the recommended pool so use the ArrayPool + newSegment.SetOwnedMemory(ArrayPool.Shared.Rent(GetSegmentSize(sizeHint))); + + _tailMemory = newSegment.AvailableMemory; + + return newSegment; + } + + private BufferSegment CreateSegment() + { + return new BufferSegment(); + } + + private static int GetSegmentSize(int sizeHint, int maxBufferSize = int.MaxValue) + { + // First we need to handle case where hint is smaller than minimum segment size + sizeHint = Math.Max(MinimumBufferSize, sizeHint); + // After that adjust it to fit into pools max buffer size + var adjustedToMaximumSize = Math.Min(maxBufferSize, sizeHint); + return adjustedToMaximumSize; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Reset(); + } + } + + public void Reset() + { + var segment = _head; + while (segment != null) + { + var returnSegment = segment; + segment = segment.NextSegment; + + // We haven't reached the tail of the linked list yet, so we can always return the returnSegment. + returnSegment.ResetMemory(); + } + + _head = _tail = null; + + _bytesBuffered = 0; + _tailBytesBuffered = 0; + } + + // Copied from https://github.com/dotnet/corefx/blob/de3902bb56f1254ec1af4bf7d092fc2c048734cc/src/System.Memory/src/System/ThrowHelper.cs + private static void ThrowArgumentOutOfRangeException(string argumentName) { throw CreateArgumentOutOfRangeException(argumentName); } + [MethodImpl(MethodImplOptions.NoInlining)] + private static Exception CreateArgumentOutOfRangeException(string argumentName) { return new ArgumentOutOfRangeException(argumentName); } + + public override void Flush() + { + _innerStream.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _innerStream.FlushAsync(cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _innerStream.Read(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _innerStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _innerStream.Write(buffer, offset, count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.WriteAsync(buffer, cancellationToken); + } + + public override void Write(ReadOnlySpan buffer) + { + _innerStream.Write(buffer); + } + + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + { + return _innerStream.BeginRead(buffer, offset, count, callback, state); + } + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + { + return _innerStream.BeginWrite(buffer, offset, count, callback, state); + } + + public override int EndRead(IAsyncResult asyncResult) + { + return _innerStream.EndRead(asyncResult); + } + + public override void EndWrite(IAsyncResult asyncResult) + { + _innerStream.EndWrite(asyncResult); + } + + public override void CopyTo(Stream destination, int bufferSize) + { + _innerStream.CopyTo(destination, bufferSize); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _innerStream.ReadAsync(buffer, cancellationToken); + } + + public override ValueTask DisposeAsync() + { + return _innerStream.DisposeAsync(); + } + + public override int Read(Span buffer) + { + return _innerStream.Read(buffer); + } + } +} diff --git a/src/Middleware/HttpLogging/src/HttpLoggingBuilderExtensions.cs b/src/Middleware/HttpLogging/src/HttpLoggingBuilderExtensions.cs new file mode 100644 index 000000000000..81d5c1f4f5a0 --- /dev/null +++ b/src/Middleware/HttpLogging/src/HttpLoggingBuilderExtensions.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.HttpLogging; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for the HttpLogging middleware. + /// + public static class HttpLoggingBuilderExtensions + { + /// + /// Adds a middleware that can log HTTP requests and responses. + /// + /// The instance this method extends. + /// The . + public static IApplicationBuilder UseHttpLogging(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + app.UseMiddleware(); + return app; + } + } +} diff --git a/src/Middleware/HttpLogging/src/HttpLoggingExtensions.cs b/src/Middleware/HttpLogging/src/HttpLoggingExtensions.cs new file mode 100644 index 000000000000..523c02efde9e --- /dev/null +++ b/src/Middleware/HttpLogging/src/HttpLoggingExtensions.cs @@ -0,0 +1,40 @@ +// 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.Extensions.Logging; + +namespace Microsoft.AspNetCore.HttpLogging +{ + internal static class HttpLoggingExtensions + { + private static readonly Action _requestBody = + LoggerMessage.Define(LogLevel.Information, new EventId(3, "RequestBody"), "RequestBody: {Body}"); + + private static readonly Action _responseBody = + LoggerMessage.Define(LogLevel.Information, new EventId(4, "ResponseBody"), "ResponseBody: {Body}"); + + private static readonly Action _decodeFailure = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "DecodeFaulure"), "Decode failure while converting body."); + + private static readonly Action _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