Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add BodyInspectionHandler #493

Merged
merged 11 commits into from
Dec 13, 2024
9 changes: 7 additions & 2 deletions src/http/httpClient/KiotaClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ public static IList<DelegatingHandler> CreateDefaultHandlers(IRequestOption[]? o
ParametersNameDecodingOption? parametersNameDecodingOption = null;
UserAgentHandlerOption? userAgentHandlerOption = null;
HeadersInspectionHandlerOption? headersInspectionHandlerOption = null;
BodyInspectionHandlerOption? bodyInspectionHandlerOption = null;

foreach(var option in optionsForHandlers)
{
Expand All @@ -102,8 +103,10 @@ public static IList<DelegatingHandler> CreateDefaultHandlers(IRequestOption[]? o
parametersNameDecodingOption = parametersOption;
else if(userAgentHandlerOption == null && option is UserAgentHandlerOption userAgentOption)
userAgentHandlerOption = userAgentOption;
else if(headersInspectionHandlerOption == null && option is HeadersInspectionHandlerOption headersOption)
headersInspectionHandlerOption = headersOption;
else if(headersInspectionHandlerOption == null && option is HeadersInspectionHandlerOption headersInspectionOption)
headersInspectionHandlerOption = headersInspectionOption;
else if(bodyInspectionHandlerOption == null && option is BodyInspectionHandlerOption bodyInspectionOption)
bodyInspectionHandlerOption = bodyInspectionOption;
}

return new List<DelegatingHandler>
Expand All @@ -114,6 +117,7 @@ public static IList<DelegatingHandler> CreateDefaultHandlers(IRequestOption[]? o
parametersNameDecodingOption != null ? new ParametersNameDecodingHandler(parametersNameDecodingOption) : new ParametersNameDecodingHandler(),
userAgentHandlerOption != null ? new UserAgentHandler(userAgentHandlerOption) : new UserAgentHandler(),
headersInspectionHandlerOption != null ? new HeadersInspectionHandler(headersInspectionHandlerOption) : new HeadersInspectionHandler(),
bodyInspectionHandlerOption != null ? new BodyInspectionHandler(bodyInspectionHandlerOption) : new BodyInspectionHandler(),
};
}

Expand All @@ -132,6 +136,7 @@ public static IList<DelegatingHandler> CreateDefaultHandlers(IRequestOption[]? o
typeof(ParametersNameDecodingHandler),
typeof(UserAgentHandler),
typeof(HeadersInspectionHandler),
typeof(BodyInspectionHandler),
};
}

Expand Down
104 changes: 104 additions & 0 deletions src/http/httpClient/Middleware/BodyInspectionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information.
// ------------------------------------------------------------------------------

using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Kiota.Http.HttpClientLibrary.Extensions;
using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;

namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware;

/// <summary>
/// The Body Inspection Handler allows the developer to inspect the body of the request and response.
/// </summary>
public class BodyInspectionHandler : DelegatingHandler
andrueastman marked this conversation as resolved.
Show resolved Hide resolved
{
private readonly BodyInspectionHandlerOption _defaultOptions;

/// <summary>
/// Create a new instance of <see cref="BodyInspectionHandler"/>
/// </summary>
/// <param name="defaultOptions">Default options to apply to the handler</param>
public BodyInspectionHandler(BodyInspectionHandlerOption? defaultOptions = null)
{
_defaultOptions = defaultOptions ?? new BodyInspectionHandlerOption();
}

/// <inheritdoc/>
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
)
{
if(request == null)
throw new ArgumentNullException(nameof(request));

var options = request.GetRequestOption<BodyInspectionHandlerOption>() ?? _defaultOptions;

Activity? activity;
if(request.GetRequestOption<ObservabilityOptions>() is { } obsOptions)
{
var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(
obsOptions.TracerInstrumentationName
);
activity = activitySource?.StartActivity(
$"{nameof(BodyInspectionHandler)}_{nameof(SendAsync)}"
);
activity?.SetTag("com.microsoft.kiota.handler.bodyInspection.enable", true);
}
else
{
activity = null;
}
try
{
if(options.InspectRequestBody)
{
options.RequestBody = await CopyToStreamAsync(request.Content);
}
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
if(options.InspectResponseBody)
{
options.ResponseBody = await CopyToStreamAsync(response.Content);
}

return response;
}
finally
{
activity?.Dispose();
}

#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER
[return: NotNullIfNotNull(nameof(httpContent))]
#endif
static async Task<Stream?> CopyToStreamAsync(HttpContent? httpContent)
{
if(httpContent == null)
{
return null;
}

if(httpContent.Headers.ContentLength == 0)
{
return Stream.Null;
}

var stream = new MemoryStream();
andrueastman marked this conversation as resolved.
Show resolved Hide resolved
await httpContent.CopyToAsync(stream);
baywet marked this conversation as resolved.
Show resolved Hide resolved

if(stream.CanSeek)
{
stream.Position = 0;
}

return stream;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// ------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information.
// ------------------------------------------------------------------------------

using System.IO;
using Microsoft.Kiota.Abstractions;

namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;

/// <summary>
/// The Body Inspection Option allows the developer to inspect the body of the request and response.
/// </summary>
public class BodyInspectionHandlerOption : IRequestOption
{
/// <summary>
/// Gets or sets a value indicating whether the request body should be inspected.
/// Note tht this setting increases memory usae as the request body is copied to a new stream.
/// </summary>
public bool InspectRequestBody { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the response body should be inspected.
/// Note tht this setting increases memory usae as the request body is copied to a new stream.
/// </summary>
public bool InspectResponseBody { get; set; }

/// <summary>
/// Gets the request body stream for the current request. This stream is available
/// only if InspectRequestBody is set to true and the request contains a body.
/// This stream is not disposed of by kiota, you need to take care of that.
/// Note that this stream is a copy of the original request body stream, which has
/// impact on memory usage. Use adequately.
/// </summary>
public Stream? RequestBody { get; internal set; }

/// <summary>
/// Gets the response body stream for the current request. This stream is available
/// only if InspectResponseBody is set to true.
/// This stream is not disposed of by kiota, you need to take care of that.
/// Note that this stream is a copy of the original request body stream, which has
/// impact on memory usage. Use adequately.
/// </summary>
public Stream? ResponseBody { get; internal set; }
}
113 changes: 113 additions & 0 deletions tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Net.Http;
using Microsoft.Kiota.Http.HttpClientLibrary.Middleware;
using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;
using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks;
using Xunit;

namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware;

public class BodyInspectionHandlerTests : IDisposable
{
private readonly List<IDisposable> _disposables = [];

[Fact]
public void BodyInspectionHandlerConstruction()
{
using var defaultValue = new BodyInspectionHandler();
Assert.NotNull(defaultValue);
}

[Fact]
public async Task BodyInspectionHandlerGetsRequestBodyStream()
{
var option = new BodyInspectionHandlerOption { InspectRequestBody = true, };
using var invoker = GetMessageInvoker(new HttpResponseMessage(), option);

// When
var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost")
{
Content = new StringContent("request test")
};
var response = await invoker.SendAsync(request, default);

// Then
Assert.NotNull(option.RequestBody);
Assert.Equal("request test", GetStringFromStream(option.RequestBody!));
Assert.Equal("request test", await request.Content.ReadAsStringAsync()); // response from option is separate from "normal" request stream
}
andrueastman marked this conversation as resolved.
Show resolved Hide resolved

[Fact]
public async Task BodyInspectionHandlerGetsNullRequestBodyStreamWhenThereIsNoRequestBody()
{
var option = new BodyInspectionHandlerOption { InspectRequestBody = true, };
using var invoker = GetMessageInvoker(new HttpResponseMessage(), option);

// When
var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost");
var response = await invoker.SendAsync(request, default);

// Then
Assert.Null(option.RequestBody);
}

[Fact]
public async Task BodyInspectionHandlerGetsResponseBodyStream()
{
var option = new BodyInspectionHandlerOption { InspectResponseBody = true, };
using var invoker = GetMessageInvoker(CreateHttpResponseWithBody(), option);

// When
var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost");
var response = await invoker.SendAsync(request, default);

// Then
Assert.NotNull(option.ResponseBody);
Assert.Equal("response test", GetStringFromStream(option.ResponseBody!));
Assert.Equal("response test", await response.Content.ReadAsStringAsync()); // response from option is separate from "normal" response stream
}

[Fact]
public async Task BodyInspectionHandlerGetsEmptyResponseBodyStreamWhenThereIsNoResponseBody()
{
var option = new BodyInspectionHandlerOption { InspectResponseBody = true, };
using var invoker = GetMessageInvoker(new HttpResponseMessage(), option);

// When
var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost");
var response = await invoker.SendAsync(request, default);

// Then
Assert.NotNull(option.ResponseBody);
Assert.Equal(string.Empty, GetStringFromStream(option.ResponseBody!));
}

private static HttpResponseMessage CreateHttpResponseWithBody() =>
new() { Content = new StringContent("response test") };

private HttpMessageInvoker GetMessageInvoker(
HttpResponseMessage httpResponseMessage,
BodyInspectionHandlerOption option
)
{
var messageHandler = new MockRedirectHandler();
_disposables.Add(messageHandler);
_disposables.Add(httpResponseMessage);
messageHandler.SetHttpResponse(httpResponseMessage);
// Given
var handler = new BodyInspectionHandler(option) { InnerHandler = messageHandler };
_disposables.Add(handler);
return new HttpMessageInvoker(handler);
}

private static string GetStringFromStream(Stream stream)
{
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}

public void Dispose()
{
_disposables.ForEach(static x => x.Dispose());
GC.SuppressFinalize(this);
}
}
Loading