File: Logging\HttpRequestReaderTest.cs
Web Access
Project: src\test\Libraries\Microsoft.Extensions.Http.Diagnostics.Tests\Microsoft.Extensions.Http.Diagnostics.Tests.csproj (Microsoft.Extensions.Http.Diagnostics.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Mime;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutoFixture;
using FluentAssertions;
using Microsoft.Extensions.Compliance.Classification;
using Microsoft.Extensions.Compliance.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Diagnostics;
using Microsoft.Extensions.Http.Logging.Internal;
using Microsoft.Extensions.Http.Logging.Test.Internal;
using Microsoft.Extensions.Telemetry.Internal;
using Moq;
using Xunit;
 
namespace Microsoft.Extensions.Http.Logging.Test;
 
public class HttpRequestReaderTest
{
    private const string Redacted = "REDACTED";
    private const string RequestedHost = "default-uri.com";
 
    private readonly Fixture _fixture;
 
    public HttpRequestReaderTest()
    {
        _fixture = new Fixture();
    }
 
    [Fact]
    public async Task ReadAsync_AllData_ReturnsLogRecord()
    {
        var requestContent = _fixture.Create<string>();
        var responseContent = _fixture.Create<string>();
        var header1 = new KeyValuePair<string, string>("Header1", "Value1");
        var header2 = new KeyValuePair<string, string>("Header2", "Value2");
        var header3 = new KeyValuePair<string, string>("Header3", "Value3");
        var expectedRecord = new LogRecord
        {
            Host = RequestedHost,
            Method = HttpMethod.Post,
            Path = TelemetryConstants.Redacted,
            StatusCode = 200,
            RequestHeaders = [new("Header1", Redacted), new("Header3", Redacted)],
            ResponseHeaders = [new("Header2", Redacted), new("Header3", Redacted)],
            RequestBody = requestContent,
            ResponseBody = responseContent,
        };
 
        var options = new LoggingOptions
        {
            RequestHeadersDataClasses = new Dictionary<string, DataClassification> { { header1.Key, FakeTaxonomy.PrivateData }, { header3.Key, FakeTaxonomy.PrivateData } },
            ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { header2.Key, FakeTaxonomy.PrivateData }, { header3.Key, FakeTaxonomy.PrivateData } },
            RequestBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            ResponseBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            BodyReadTimeout = TimeSpan.FromSeconds(100000),
            LogBody = true,
        };
 
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var serviceKey = "my-key";
        var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(serviceKey), mockHeadersRedactor.Object, serviceKey);
        using var serviceProvider = GetServiceProvider(headersReader, serviceKey);
 
        var reader = new HttpRequestReader(serviceProvider, options.ToOptionsMonitor(serviceKey), serviceProvider.GetRequiredService<IHttpRouteFormatter>(),
            serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext, serviceKey: serviceKey);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri("http://default-uri.com/foo"),
            Content = new StringContent(requestContent, Encoding.UTF8)
        };
 
        httpRequestMessage.Headers.Add(header1.Key, header1.Value);
        httpRequestMessage.Headers.Add(header3.Key, header3.Value);
 
        using var httpResponseMessage = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(responseContent, Encoding.UTF8)
        };
 
        httpResponseMessage.Headers.Add(header2.Key, header2.Value);
        httpResponseMessage.Headers.Add(header3.Key, header3.Value);
 
        var logRecord = new LogRecord();
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        await reader.ReadRequestAsync(logRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
        await reader.ReadResponseAsync(logRecord, httpResponseMessage, responseHeadersBuffer, CancellationToken.None);
 
        logRecord.Should().BeEquivalentTo(expectedRecord);
    }
 
    [Fact]
    public async Task ReadAsync_NoHost_ReturnsLogRecordWithoutHost()
    {
        var requestContent = _fixture.Create<string>();
        var responseContent = _fixture.Create<string>();
        const string PlainTextMedia = "text/plain";
 
        var expectedRecord = new LogRecord
        {
            Host = TelemetryConstants.Unknown,
            Method = HttpMethod.Post,
            Path = TelemetryConstants.Unknown,
            StatusCode = 200,
            RequestBody = requestContent,
            ResponseBody = responseContent,
        };
 
        var options = new LoggingOptions
        {
            RequestBodyContentTypes = new HashSet<string> { PlainTextMedia },
            ResponseBodyContentTypes = new HashSet<string> { PlainTextMedia },
            BodyReadTimeout = TimeSpan.FromSeconds(10),
            LogBody = true,
        };
 
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object);
        using var serviceProvider = GetServiceProvider(headersReader);
 
        var reader = new HttpRequestReader(serviceProvider, options.ToOptionsMonitor(), serviceProvider.GetRequiredService<IHttpRouteFormatter>(),
            serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = null,
            Content = new StringContent(requestContent, Encoding.UTF8)
        };
 
        using var httpResponseMessage = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(responseContent, Encoding.UTF8)
        };
 
        var actualRecord = new LogRecord();
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        await reader.ReadRequestAsync(actualRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
        await reader.ReadResponseAsync(actualRecord, httpResponseMessage, responseHeadersBuffer, CancellationToken.None);
 
        actualRecord.Should().BeEquivalentTo(expectedRecord);
    }
 
    [Fact]
    public async Task ReadAsync_AllDataWithRequestMetadataSet_ReturnsLogRecord()
    {
        var requestContent = _fixture.Create<string>();
        var responseContent = _fixture.Create<string>();
        var header1 = new KeyValuePair<string, string>("Header1", "Value1");
        var header2 = new KeyValuePair<string, string>("Header2", "Value2");
 
        var expectedRecord = new LogRecord
        {
            Host = RequestedHost,
            Method = HttpMethod.Post,
            Path = "foo/bar/123",
            StatusCode = 200,
            RequestHeaders = [new("Header1", Redacted)],
            ResponseHeaders = [new("Header2", Redacted)],
            RequestBody = requestContent,
            ResponseBody = responseContent,
        };
 
        var opts = new LoggingOptions
        {
            RequestHeadersDataClasses = new Dictionary<string, DataClassification> { { header1.Key, FakeTaxonomy.PrivateData } },
            ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { header2.Key, FakeTaxonomy.PrivateData } },
            RequestBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            ResponseBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            BodyReadTimeout = TimeSpan.FromSeconds(10),
            LogBody = true,
        };
 
        opts.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var headersReader = new HttpHeadersReader(opts.ToOptionsMonitor(), mockHeadersRedactor.Object);
        using var serviceProvider = GetServiceProvider(headersReader);
 
        var reader = new HttpRequestReader(serviceProvider, opts.ToOptionsMonitor(),
            serviceProvider.GetRequiredService<IHttpRouteFormatter>(), serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri("http://default-uri.com/foo/bar/123"),
            Content = new StringContent(requestContent, Encoding.UTF8),
        };
 
        httpRequestMessage.Headers.Add(header1.Key, header1.Value);
        httpRequestMessage.SetRequestMetadata(new RequestMetadata
        {
            RequestRoute = "/foo/bar/{userId}"
        });
 
        using var httpResponseMessage = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(responseContent, Encoding.UTF8)
        };
 
        httpResponseMessage.Headers.Add(header2.Key, header2.Value);
 
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        var actualRecord = new LogRecord();
        await reader.ReadRequestAsync(actualRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
        await reader.ReadResponseAsync(actualRecord, httpResponseMessage, responseHeadersBuffer, CancellationToken.None);
 
        actualRecord.Should().BeEquivalentTo(expectedRecord);
    }
 
    [Fact]
    public async Task ReadAsync_FormatRequestPathDisabled_ReturnsLogRecordWithRoute()
    {
        var requestContent = _fixture.Create<string>();
        var responseContent = _fixture.Create<string>();
        var header1 = new KeyValuePair<string, string>("Header1", "Value1");
        var header2 = new KeyValuePair<string, string>("Header2", "Value2");
 
        var expectedRecord = new LogRecord
        {
            Host = RequestedHost,
            Method = HttpMethod.Post,
            Path = "foo/bar/{userId}",
            StatusCode = 200,
            RequestHeaders = [new("Header1", Redacted)],
            ResponseHeaders = [new("Header2", Redacted)],
            RequestBody = requestContent,
            ResponseBody = responseContent,
            PathParametersCount = 1
        };
 
        var opts = new LoggingOptions
        {
            LogRequestStart = true,
            LogBody = true,
            RequestHeadersDataClasses = new Dictionary<string, DataClassification> { { header1.Key, FakeTaxonomy.PrivateData } },
            ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { header2.Key, FakeTaxonomy.PrivateData } },
            RequestBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            ResponseBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            BodyReadTimeout = TimeSpan.FromSeconds(10),
            RequestPathLoggingMode = OutgoingPathLoggingMode.Structured
        };
 
        opts.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
 
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var headersReader = new HttpHeadersReader(opts.ToOptionsMonitor(), mockHeadersRedactor.Object);
        using var serviceProvider = GetServiceProvider(headersReader, configureRedaction: x => x.RedactionFormat = Redacted);
 
        var reader = new HttpRequestReader(serviceProvider, opts.ToOptionsMonitor(),
            serviceProvider.GetRequiredService<IHttpRouteFormatter>(), serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri($"http://{RequestedHost}/foo/bar/123"),
            Content = new StringContent(requestContent, Encoding.UTF8),
        };
 
        httpRequestMessage.Headers.Add(header1.Key, header1.Value);
        httpRequestMessage.SetRequestMetadata(new RequestMetadata
        {
            RequestRoute = "foo/bar/{userId}"
        });
 
        using var httpResponseMessage = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(responseContent, Encoding.UTF8)
        };
 
        httpResponseMessage.Headers.Add(header2.Key, header2.Value);
 
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        var actualRecord = new LogRecord();
        await reader.ReadRequestAsync(actualRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
        await reader.ReadResponseAsync(actualRecord, httpResponseMessage, responseHeadersBuffer, CancellationToken.None);
 
        actualRecord.Should().BeEquivalentTo(expectedRecord, o => o.Excluding(x => x.PathParameters));
 
        HttpRouteParameter[] expectedParameters = [new("userId", Redacted, true)];
        actualRecord.PathParameters.Should().NotBeNull().And.Subject.Take(actualRecord.PathParametersCount).Should().BeEquivalentTo(expectedParameters);
    }
 
    [Fact]
    public async Task ReadAsync_RouteParameterRedactionModeNone_ReturnsLogRecordWithUnredactedRoute()
    {
        var requestContent = _fixture.Create<string>();
        var header1 = new KeyValuePair<string, string>("Header1", "Value1");
        var header2 = new KeyValuePair<string, string>("Header2", "Value2");
 
        var expectedRecord = new LogRecord
        {
            Host = RequestedHost,
            Method = HttpMethod.Post,
            Path = "/foo/bar/123",
            RequestHeaders = [new("Header1", Redacted)],
            RequestBody = requestContent,
        };
 
        var opts = new LoggingOptions
        {
            LogRequestStart = true,
            LogBody = true,
            RequestPathParameterRedactionMode = HttpRouteParameterRedactionMode.None,
            RequestHeadersDataClasses = new Dictionary<string, DataClassification> { { header1.Key, FakeTaxonomy.PrivateData } },
            RequestBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            BodyReadTimeout = TimeSpan.FromSeconds(10),
            RequestPathLoggingMode = OutgoingPathLoggingMode.Structured
        };
 
        opts.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
 
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var headersReader = new HttpHeadersReader(opts.ToOptionsMonitor(), mockHeadersRedactor.Object);
        using var serviceProvider = GetServiceProvider(headersReader);
 
        var reader = new HttpRequestReader(serviceProvider, opts.ToOptionsMonitor(),
            serviceProvider.GetRequiredService<IHttpRouteFormatter>(), serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri("http://default-uri.com/foo/bar/123"),
            Content = new StringContent(requestContent, Encoding.UTF8),
        };
 
        httpRequestMessage.Headers.Add(header1.Key, header1.Value);
 
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        var actualRecord = new LogRecord();
        await reader.ReadRequestAsync(actualRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
 
        actualRecord.Should().BeEquivalentTo(expectedRecord);
    }
 
    [Fact]
    public async Task ReadAsync_RequestMetadataRequestNameSetAndRouteMissing_ReturnsLogRecord()
    {
        var requestContent = _fixture.Create<string>();
        var responseContent = _fixture.Create<string>();
        var header1 = new KeyValuePair<string, string>("Header1", "Value1");
        var header2 = new KeyValuePair<string, string>("Header2", "Value2");
 
        var expectedRecord = new LogRecord
        {
            Host = RequestedHost,
            Method = HttpMethod.Post,
            Path = "TestRequest",
            StatusCode = 200,
            RequestHeaders = [new("Header1", Redacted)],
            ResponseHeaders = [new("Header2", Redacted)],
            RequestBody = requestContent,
            ResponseBody = responseContent,
        };
 
        var opts = new LoggingOptions
        {
            RequestHeadersDataClasses = new Dictionary<string, DataClassification> { { header1.Key, FakeTaxonomy.PrivateData } },
            ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { header2.Key, FakeTaxonomy.PrivateData } },
            RequestBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            ResponseBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            BodyReadTimeout = TimeSpan.FromSeconds(10),
            LogBody = true,
        };
 
        opts.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var headersReader = new HttpHeadersReader(opts.ToOptionsMonitor(), mockHeadersRedactor.Object);
        using var serviceProvider = GetServiceProvider(headersReader);
 
        var reader = new HttpRequestReader(serviceProvider, opts.ToOptionsMonitor(),
            serviceProvider.GetRequiredService<IHttpRouteFormatter>(), serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri("http://default-uri.com/foo/bar/123"),
            Content = new StringContent(requestContent, Encoding.UTF8),
        };
 
        httpRequestMessage.Headers.Add(header1.Key, header1.Value);
        httpRequestMessage.SetRequestMetadata(new RequestMetadata
        {
            RequestName = "TestRequest"
        });
 
        using var httpResponseMessage = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(responseContent, Encoding.UTF8)
        };
 
        httpResponseMessage.Headers.Add(header2.Key, header2.Value);
 
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        var actualRecord = new LogRecord();
        await reader.ReadRequestAsync(actualRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
        await reader.ReadResponseAsync(actualRecord, httpResponseMessage, responseHeadersBuffer, CancellationToken.None);
 
        actualRecord.Should().BeEquivalentTo(expectedRecord);
    }
 
    [Fact]
    public async Task ReadAsync_NoMetadataUsesRedactedString_ReturnsLogRecord()
    {
        var requestContent = _fixture.Create<string>();
        var responseContent = _fixture.Create<string>();
        var header1 = new KeyValuePair<string, string>("Header1", "Value1");
        var header2 = new KeyValuePair<string, string>("Header2", "Value2");
 
        var expectedRecord = new LogRecord
        {
            Host = RequestedHost,
            Method = HttpMethod.Post,
            Path = TelemetryConstants.Redacted,
            StatusCode = 200,
            RequestHeaders = [new("Header1", Redacted)],
            ResponseHeaders = [new("Header2", Redacted)],
            RequestBody = requestContent,
            ResponseBody = responseContent,
        };
 
        var opts = new LoggingOptions
        {
            RequestHeadersDataClasses = new Dictionary<string, DataClassification> { { header1.Key, FakeTaxonomy.PrivateData } },
            ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { header2.Key, FakeTaxonomy.PrivateData } },
            RequestBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            ResponseBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            BodyReadTimeout = TimeSpan.FromSeconds(10),
            LogBody = true,
        };
 
        opts.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var headersReader = new HttpHeadersReader(opts.ToOptionsMonitor(), mockHeadersRedactor.Object);
        using var serviceProvider = GetServiceProvider(headersReader);
 
        var reader = new HttpRequestReader(serviceProvider, opts.ToOptionsMonitor(),
            serviceProvider.GetRequiredService<IHttpRouteFormatter>(), serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri("http://default-uri.com/foo/bar/123"),
            Content = new StringContent(requestContent, Encoding.UTF8),
        };
 
        httpRequestMessage.Headers.Add(header1.Key, header1.Value);
 
        using var httpResponseMessage = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(responseContent, Encoding.UTF8)
        };
 
        httpResponseMessage.Headers.Add(header2.Key, header2.Value);
 
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        var actualRecord = new LogRecord();
        await reader.ReadRequestAsync(actualRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
        await reader.ReadResponseAsync(actualRecord, httpResponseMessage, responseHeadersBuffer, CancellationToken.None);
 
        actualRecord.Should().BeEquivalentTo(expectedRecord);
    }
 
    [Fact]
    public async Task ReadAsync_MetadataWithoutRequestRouteOrNameUsesConstants_ReturnsLogRecord()
    {
        var requestContent = _fixture.Create<string>();
        var responseContent = _fixture.Create<string>();
        var header1 = new KeyValuePair<string, string>("Header1", "Value1");
        var header2 = new KeyValuePair<string, string>("Header2", "Value2");
 
        var expectedRecord = new LogRecord
        {
            Host = RequestedHost,
            Method = HttpMethod.Post,
            Path = TelemetryConstants.Unknown,
            StatusCode = 200,
            RequestHeaders = [new("Header1", Redacted)],
            ResponseHeaders = [new("Header2", Redacted)],
            RequestBody = requestContent,
            ResponseBody = responseContent,
        };
 
        var opts = new LoggingOptions
        {
            RequestHeadersDataClasses = new Dictionary<string, DataClassification> { { header1.Key, FakeTaxonomy.PrivateData } },
            ResponseHeadersDataClasses = new Dictionary<string, DataClassification> { { header2.Key, FakeTaxonomy.PrivateData } },
            RequestBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            ResponseBodyContentTypes = new HashSet<string> { MediaTypeNames.Text.Plain },
            BodyReadTimeout = TimeSpan.FromSeconds(10),
            LogBody = true,
        };
 
        opts.RouteParameterDataClasses.Add("userId", FakeTaxonomy.PrivateData);
        var mockHeadersRedactor = new Mock<IHttpHeadersRedactor>();
        mockHeadersRedactor.Setup(r => r.Redact(It.IsAny<IEnumerable<string>>(), It.IsAny<DataClassification>()))
            .Returns(Redacted);
 
        var headersReader = new HttpHeadersReader(opts.ToOptionsMonitor(), mockHeadersRedactor.Object);
        using var serviceProvider = GetServiceProvider(headersReader);
 
        var reader = new HttpRequestReader(serviceProvider, opts.ToOptionsMonitor(),
            serviceProvider.GetRequiredService<IHttpRouteFormatter>(), serviceProvider.GetRequiredService<IHttpRouteParser>(), RequestMetadataContext);
 
        using var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri("http://default-uri.com/foo/bar/123"),
            Content = new StringContent(requestContent, Encoding.UTF8),
        };
 
        httpRequestMessage.Headers.Add(header1.Key, header1.Value);
        httpRequestMessage.SetRequestMetadata(new RequestMetadata());
 
        using var httpResponseMessage = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(responseContent, Encoding.UTF8)
        };
 
        httpResponseMessage.Headers.Add(header2.Key, header2.Value);
 
        var requestHeadersBuffer = new List<KeyValuePair<string, string>>();
        var responseHeadersBuffer = new List<KeyValuePair<string, string>>();
        var actualRecord = new LogRecord();
        await reader.ReadRequestAsync(actualRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None);
        await reader.ReadResponseAsync(actualRecord, httpResponseMessage, responseHeadersBuffer, CancellationToken.None);
 
        actualRecord.Should().BeEquivalentTo(expectedRecord);
    }
 
    private static ServiceProvider GetServiceProvider(
        HttpHeadersReader headersReader,
        string? serviceKey = null,
        Action<FakeRedactorOptions>? configureRedaction = null)
    {
        var services = new ServiceCollection();
        if (serviceKey is null)
        {
            _ = services.AddSingleton<IHttpHeadersReader>(headersReader);
        }
        else
        {
            _ = services.AddKeyedSingleton<IHttpHeadersReader>(serviceKey, headersReader);
        }
 
        return services
            .AddFakeRedaction(configureRedaction ?? (_ => { }))
            .AddHttpRouteProcessor()
            .BuildServiceProvider();
    }
 
    private static IOutgoingRequestContext RequestMetadataContext => Mock.Of<IOutgoingRequestContext>();
}