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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.16.0] - 2024-12-13

### Added

- Added body inspection handler to enable inspection of request and response bodies. [#482](https://github.com/microsoft/kiota-dotnet/issues/482)

## [1.15.2] - 2024-11-13

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>
<!-- Common default project properties for ALL projects-->
<PropertyGroup>
<VersionPrefix>1.15.2</VersionPrefix>
<VersionPrefix>1.16.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
<!-- This is overidden in test projects by setting to true-->
<IsTestProject>false</IsTestProject>
Expand Down
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
109 changes: 109 additions & 0 deletions src/http/httpClient/Middleware/BodyInspectionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// ------------------------------------------------------------------------------
// 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, cancellationToken)
.ConfigureAwait(false);
}
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
if(options.InspectResponseBody)
{
options.ResponseBody = await CopyToStreamAsync(response.Content, cancellationToken)
.ConfigureAwait(false);
}

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,
CancellationToken cancellationToken
)
{
if(httpContent is null or { Headers.ContentLength: 0 })
{
return Stream.Null;
}

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

#if NET5_0_OR_GREATER
await httpContent.CopyToAsync(stream, cancellationToken).ConfigureAwait(false);
baywet marked this conversation as resolved.
Show resolved Hide resolved
#else
await httpContent.CopyToAsync(stream).ConfigureAwait(false);
#endif

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. Otherwise,
/// it's just Stream.Null. 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; } = Stream.Null;

/// <summary>
/// Gets the response body stream for the current request. This stream is available
/// only if InspectResponseBody is set to true and the response contains a body. Otherwise,
/// it's just Stream.Null. 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; } = Stream.Null;
}
138 changes: 138 additions & 0 deletions tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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.Equal("request test", GetStringFromStream(option.RequestBody!));
Assert.Equal("request test", await request.Content.ReadAsStringAsync()); // response from option is separate from "normal" request stream
}

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

// When
var memoryStream = new MemoryStream();
var writer = new StreamWriter(memoryStream);
await writer.WriteAsync("request test");
await writer.FlushAsync();
memoryStream.Seek(0, SeekOrigin.Begin);

var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost")
{
Content = new StreamContent(memoryStream)
};
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(
"application/octet-stream"
);

var response = await invoker.SendAsync(request, default);

// Then
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.Same(Stream.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.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 BodyInspectionHandlerGetsNullResponseBodyStreamWhenThereIsNoResponseBody()
{
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.Same(Stream.Null, 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