From 6d02eeff815915f12cb830180243532743c2a211 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:00:06 +0000 Subject: [PATCH] .Net: Allow customization of building REST API operation URL, payload, and headers (#9985) ### Motivation and Context CopilotAgentPlugin functionality may need more control over the way url, headers and payload are created. ### Description This PR adds internal factories for creating URLs, headers, and payloads. The factories are kept internal because the necessity of having them and their structure may change in the future. --- .../Functions.OpenApi/HttpContentFactory.cs | 2 +- .../Model/RestApiOperationHeadersFactory.cs | 14 +++ .../Model/RestApiOperationPayloadFactory.cs | 23 ++++ .../Model/RestApiOperationUrlFactory.cs | 15 +++ .../RestApiOperationRunner.cs | 38 ++++-- .../OpenApi/RestApiOperationRunnerTests.cs | 115 ++++++++++++++++++ 6 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationHeadersFactory.cs create mode 100644 dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationPayloadFactory.cs create mode 100644 dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationUrlFactory.cs diff --git a/dotnet/src/Functions/Functions.OpenApi/HttpContentFactory.cs b/dotnet/src/Functions/Functions.OpenApi/HttpContentFactory.cs index c3ebf9251e0a..45cea8a3ec3a 100644 --- a/dotnet/src/Functions/Functions.OpenApi/HttpContentFactory.cs +++ b/dotnet/src/Functions/Functions.OpenApi/HttpContentFactory.cs @@ -11,4 +11,4 @@ namespace Microsoft.SemanticKernel.Plugins.OpenApi; /// The operation payload metadata. /// The operation arguments. /// The object and HttpContent representing the operation payload. -internal delegate (object? Payload, HttpContent Content) HttpContentFactory(RestApiPayload? payload, IDictionary arguments); +internal delegate (object Payload, HttpContent Content) HttpContentFactory(RestApiPayload? payload, IDictionary arguments); diff --git a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationHeadersFactory.cs b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationHeadersFactory.cs new file mode 100644 index 000000000000..738a47a670f8 --- /dev/null +++ b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationHeadersFactory.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Plugins.OpenApi; + +/// +/// Represents a delegate for creating headers for a REST API operation. +/// +/// The REST API operation. +/// The arguments for the operation. +/// The operation run options. +/// The operation headers. +internal delegate IDictionary? RestApiOperationHeadersFactory(RestApiOperation operation, IDictionary arguments, RestApiOperationRunOptions? options); diff --git a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationPayloadFactory.cs b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationPayloadFactory.cs new file mode 100644 index 000000000000..1000a616fe73 --- /dev/null +++ b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationPayloadFactory.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Net.Http; + +namespace Microsoft.SemanticKernel.Plugins.OpenApi; + +/// +/// Represents a delegate for creating a payload for a REST API operation. +/// +/// The REST API operation. +/// The arguments for the operation. +/// +/// Determines whether the operation payload is constructed dynamically based on operation payload metadata. +/// If false, the operation payload must be provided via the 'payload' property. +/// +/// +/// Determines whether payload parameters are resolved from the arguments by +/// full name (parameter name prefixed with the parent property name). +/// +/// The operation run options. +/// The operation payload. +internal delegate (object Payload, HttpContent Content)? RestApiOperationPayloadFactory(RestApiOperation operation, IDictionary arguments, bool enableDynamicPayload, bool enablePayloadNamespacing, RestApiOperationRunOptions? options); diff --git a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationUrlFactory.cs b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationUrlFactory.cs new file mode 100644 index 000000000000..64736c6decbe --- /dev/null +++ b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperationUrlFactory.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Plugins.OpenApi; + +/// +/// Represents a delegate for creating a URL for a REST API operation. +/// +/// The REST API operation. +/// The arguments for the operation. +/// The operation run options. +/// The operation URL. +internal delegate Uri? RestApiOperationUrlFactory(RestApiOperation operation, IDictionary arguments, RestApiOperationRunOptions? options); diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index 29b58fa6b480..9c1c2bcb1177 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -88,6 +88,21 @@ internal sealed class RestApiOperationRunner /// private readonly HttpResponseContentReader? _httpResponseContentReader; + /// + /// The external URL factory to use if provided, instead of the default one. + /// + private readonly RestApiOperationUrlFactory? _urlFactory; + + /// + /// The external header factory to use if provided, instead of the default one. + /// + private readonly RestApiOperationHeadersFactory? _headersFactory; + + /// + /// The external payload factory to use if provided, instead of the default one. + /// + private readonly RestApiOperationPayloadFactory? _payloadFactory; + /// /// Creates an instance of the class. /// @@ -100,19 +115,28 @@ internal sealed class RestApiOperationRunner /// Determines whether payload parameters are resolved from the arguments by /// full name (parameter name prefixed with the parent property name). /// Custom HTTP response content reader. + /// The external URL factory to use if provided if provided instead of the default one. + /// The external headers factory to use if provided instead of the default one. + /// The external payload factory to use if provided instead of the default one. public RestApiOperationRunner( HttpClient httpClient, AuthenticateRequestAsyncCallback? authCallback = null, string? userAgent = null, bool enableDynamicPayload = false, bool enablePayloadNamespacing = false, - HttpResponseContentReader? httpResponseContentReader = null) + HttpResponseContentReader? httpResponseContentReader = null, + RestApiOperationUrlFactory? urlFactory = null, + RestApiOperationHeadersFactory? headersFactory = null, + RestApiOperationPayloadFactory? payloadFactory = null) { this._httpClient = httpClient; this._userAgent = userAgent ?? HttpHeaderConstant.Values.UserAgent; this._enableDynamicPayload = enableDynamicPayload; this._enablePayloadNamespacing = enablePayloadNamespacing; this._httpResponseContentReader = httpResponseContentReader; + this._urlFactory = urlFactory; + this._headersFactory = headersFactory; + this._payloadFactory = payloadFactory; // If no auth callback provided, use empty function if (authCallback is null) @@ -145,13 +169,13 @@ public Task RunAsync( RestApiOperationRunOptions? options = null, CancellationToken cancellationToken = default) { - var url = this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl); + var url = this._urlFactory?.Invoke(operation, arguments, options) ?? this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl); - var headers = operation.BuildHeaders(arguments); + var headers = this._headersFactory?.Invoke(operation, arguments, options) ?? operation.BuildHeaders(arguments); - var operationPayload = this.BuildOperationPayload(operation, arguments); + var (Payload, Content) = this._payloadFactory?.Invoke(operation, arguments, this._enableDynamicPayload, this._enablePayloadNamespacing, options) ?? this.BuildOperationPayload(operation, arguments); - return this.SendAsync(url, operation.Method, headers, operationPayload.Payload, operationPayload.Content, operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), options, cancellationToken); + return this.SendAsync(url, operation.Method, headers, Payload, Content, operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), options, cancellationToken); } #region private @@ -340,7 +364,7 @@ private async Task ReadContentAndCreateOperationRespon /// The payload meta-data. /// The payload arguments. /// The JSON payload the corresponding HttpContent. - private (object? Payload, HttpContent Content) BuildJsonPayload(RestApiPayload? payloadMetadata, IDictionary arguments) + private (object Payload, HttpContent Content) BuildJsonPayload(RestApiPayload? payloadMetadata, IDictionary arguments) { // Build operation payload dynamically if (this._enableDynamicPayload) @@ -440,7 +464,7 @@ private JsonObject BuildJsonObject(IList properties, IDi /// The payload meta-data. /// The payload arguments. /// The text payload and corresponding HttpContent. - private (object? Payload, HttpContent Content) BuildPlainTextPayload(RestApiPayload? payloadMetadata, IDictionary arguments) + private (object Payload, HttpContent Content) BuildPlainTextPayload(RestApiPayload? payloadMetadata, IDictionary arguments) { if (!arguments.TryGetValue(RestApiOperation.PayloadArgumentName, out object? argument) || argument is not string payload) { diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs index e30d115aaece..089644ad7848 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs @@ -1517,6 +1517,121 @@ public async Task ItShouldUseRestApiOperationPayloadPropertyNameToLookupArgument Assert.Equal("true", enabledProperty.ToString()); } + [Fact] + public async Task ItShouldUseUrlHeaderAndPayloadFactoriesIfProvidedAsync() + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Content = new StringContent("fake-content", Encoding.UTF8, MediaTypeNames.Application.Json); + + List payloadProperties = + [ + new("name", "string", true, []) + ]; + + var payload = new RestApiPayload(MediaTypeNames.Application.Json, payloadProperties); + + var expectedOperation = new RestApiOperation( + id: "fake-id", + servers: [new RestApiServer("https://fake-random-test-host")], + path: "fake-path", + method: HttpMethod.Post, + description: "fake-description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [], + payload: payload + ); + + var expectedArguments = new KernelArguments(); + + var expectedOptions = new RestApiOperationRunOptions() + { + Kernel = new(), + KernelFunction = KernelFunctionFactory.CreateFromMethod(() => false), + KernelArguments = expectedArguments, + }; + + bool createUrlFactoryCalled = false; + bool createHeadersFactoryCalled = false; + bool createPayloadFactoryCalled = false; + + Uri CreateUrl(RestApiOperation operation, IDictionary arguments, RestApiOperationRunOptions? options) + { + createUrlFactoryCalled = true; + Assert.Same(expectedOperation, operation); + Assert.Same(expectedArguments, arguments); + Assert.Same(expectedOptions, options); + + return new Uri("https://fake-random-test-host-from-factory/"); + } + + IDictionary? CreateHeaders(RestApiOperation operation, IDictionary arguments, RestApiOperationRunOptions? options) + { + createHeadersFactoryCalled = true; + Assert.Same(expectedOperation, operation); + Assert.Same(expectedArguments, arguments); + Assert.Same(expectedOptions, options); + + return new Dictionary() { ["header-from-factory"] = "value-of-header-from-factory" }; + } + + (object Payload, HttpContent Content)? CreatePayload(RestApiOperation operation, IDictionary arguments, bool enableDynamicPayload, bool enablePayloadNamespacing, RestApiOperationRunOptions? options) + { + createPayloadFactoryCalled = true; + Assert.Same(expectedOperation, operation); + Assert.Same(expectedArguments, arguments); + Assert.True(enableDynamicPayload); + Assert.True(enablePayloadNamespacing); + Assert.Same(expectedOptions, options); + + var json = """{"name":"fake-name-value"}"""; + + return ((JsonObject)JsonObject.Parse(json)!, new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json)); + } + + var sut = new RestApiOperationRunner( + this._httpClient, + enableDynamicPayload: true, + enablePayloadNamespacing: true, + urlFactory: CreateUrl, + headersFactory: CreateHeaders, + payloadFactory: CreatePayload); + + // Act + var result = await sut.RunAsync(expectedOperation, expectedArguments, expectedOptions); + + // Assert + Assert.True(createUrlFactoryCalled); + Assert.True(createHeadersFactoryCalled); + Assert.True(createPayloadFactoryCalled); + + // Assert url factory + Assert.NotNull(this._httpMessageHandlerStub.RequestUri); + Assert.Equal("https://fake-random-test-host-from-factory/", this._httpMessageHandlerStub.RequestUri.AbsoluteUri); + + // Assert headers factory + Assert.NotNull(this._httpMessageHandlerStub.RequestHeaders); + Assert.Equal(3, this._httpMessageHandlerStub.RequestHeaders.Count()); + + Assert.Contains(this._httpMessageHandlerStub.RequestHeaders, h => h.Key == "header-from-factory" && h.Value.Contains("value-of-header-from-factory")); + Assert.Contains(this._httpMessageHandlerStub.RequestHeaders, h => h.Key == "User-Agent" && h.Value.Contains("Semantic-Kernel")); + Assert.Contains(this._httpMessageHandlerStub.RequestHeaders, h => h.Key == "Semantic-Kernel-Version"); + + // Assert payload factory + var messageContent = this._httpMessageHandlerStub.RequestContent; + Assert.NotNull(messageContent); + + var deserializedPayload = await JsonNode.ParseAsync(new MemoryStream(messageContent)); + Assert.NotNull(deserializedPayload); + + var nameProperty = deserializedPayload["name"]?.ToString(); + Assert.Equal("fake-name-value", nameProperty); + + Assert.NotNull(result.RequestPayload); + Assert.IsType(result.RequestPayload); + Assert.Equal("""{"name":"fake-name-value"}""", ((JsonObject)result.RequestPayload).ToJsonString()); + } + public class SchemaTestData : IEnumerable { public IEnumerator GetEnumerator()