Skip to content

Commit

Permalink
feat: add BodyInspectionHandler (#493)
Browse files Browse the repository at this point in the history
* feat: add BodyInspectionHandler

Closes #482

* Fix tracing names

* Register in default list

* Add test to make sure request is still valid

* Skip copy when non-seekable stream

* dotnet format

* configureAwait + cancellationToken

* Fix cancellation token

* Test octet request stream

* Use Stream.Null instead of null and fix net462 issues

* bump version and release notes

---------

Co-authored-by: Andrew Omondi <[email protected]>
  • Loading branch information
marcinjahn and Andrew Omondi authored Dec 13, 2024
1 parent b5bb2cd commit 3bc8cbd
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 3 deletions.
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
{
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();

#if NET5_0_OR_GREATER
await httpContent.CopyToAsync(stream, cancellationToken).ConfigureAwait(false);
#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
}

[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);
}
}

0 comments on commit 3bc8cbd

Please sign in to comment.