diff --git a/CHANGELOG.md b/CHANGELOG.md index 923c9fc7..495ff05e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props index 400480e6..dd8db0b4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 1.15.2 + 1.16.0 false diff --git a/src/http/httpClient/KiotaClientFactory.cs b/src/http/httpClient/KiotaClientFactory.cs index 2f647c5c..d7dee716 100644 --- a/src/http/httpClient/KiotaClientFactory.cs +++ b/src/http/httpClient/KiotaClientFactory.cs @@ -89,6 +89,7 @@ public static IList CreateDefaultHandlers(IRequestOption[]? o ParametersNameDecodingOption? parametersNameDecodingOption = null; UserAgentHandlerOption? userAgentHandlerOption = null; HeadersInspectionHandlerOption? headersInspectionHandlerOption = null; + BodyInspectionHandlerOption? bodyInspectionHandlerOption = null; foreach(var option in optionsForHandlers) { @@ -102,8 +103,10 @@ public static IList 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 @@ -114,6 +117,7 @@ public static IList 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(), }; } @@ -132,6 +136,7 @@ public static IList CreateDefaultHandlers(IRequestOption[]? o typeof(ParametersNameDecodingHandler), typeof(UserAgentHandler), typeof(HeadersInspectionHandler), + typeof(BodyInspectionHandler), }; } diff --git a/src/http/httpClient/Middleware/BodyInspectionHandler.cs b/src/http/httpClient/Middleware/BodyInspectionHandler.cs new file mode 100644 index 00000000..59b193c5 --- /dev/null +++ b/src/http/httpClient/Middleware/BodyInspectionHandler.cs @@ -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; + +/// +/// The Body Inspection Handler allows the developer to inspect the body of the request and response. +/// +public class BodyInspectionHandler : DelegatingHandler +{ + private readonly BodyInspectionHandlerOption _defaultOptions; + + /// + /// Create a new instance of + /// + /// Default options to apply to the handler + public BodyInspectionHandler(BodyInspectionHandlerOption? defaultOptions = null) + { + _defaultOptions = defaultOptions ?? new BodyInspectionHandlerOption(); + } + + /// + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + if(request == null) + throw new ArgumentNullException(nameof(request)); + + var options = request.GetRequestOption() ?? _defaultOptions; + + Activity? activity; + if(request.GetRequestOption() 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 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; + } + } +} diff --git a/src/http/httpClient/Middleware/Options/BodyInspectionHandlerOption.cs b/src/http/httpClient/Middleware/Options/BodyInspectionHandlerOption.cs new file mode 100644 index 00000000..0afe71eb --- /dev/null +++ b/src/http/httpClient/Middleware/Options/BodyInspectionHandlerOption.cs @@ -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; + +/// +/// The Body Inspection Option allows the developer to inspect the body of the request and response. +/// +public class BodyInspectionHandlerOption : IRequestOption +{ + /// + /// 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. + /// + public bool InspectRequestBody { get; set; } + + /// + /// 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. + /// + public bool InspectResponseBody { get; set; } + + /// + /// 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. + /// + public Stream RequestBody { get; internal set; } = Stream.Null; + + /// + /// 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. + /// + public Stream ResponseBody { get; internal set; } = Stream.Null; +} diff --git a/tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs b/tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs new file mode 100644 index 00000000..176d8a6e --- /dev/null +++ b/tests/http/httpClient/Middleware/BodyInspectionHandlerTests.cs @@ -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 _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); + } +}