Skip to content

Commit

Permalink
.Net: Allow customization of building REST API operation URL, payload…
Browse files Browse the repository at this point in the history
…, 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.
  • Loading branch information
SergeyMenshykh authored Dec 16, 2024
1 parent 7c25ac4 commit 6d02eef
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ namespace Microsoft.SemanticKernel.Plugins.OpenApi;
/// <param name="payload">The operation payload metadata.</param>
/// <param name="arguments">The operation arguments.</param>
/// <returns>The object and HttpContent representing the operation payload.</returns>
internal delegate (object? Payload, HttpContent Content) HttpContentFactory(RestApiPayload? payload, IDictionary<string, object?> arguments);
internal delegate (object Payload, HttpContent Content) HttpContentFactory(RestApiPayload? payload, IDictionary<string, object?> arguments);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;

namespace Microsoft.SemanticKernel.Plugins.OpenApi;

/// <summary>
/// Represents a delegate for creating headers for a REST API operation.
/// </summary>
/// <param name="operation">The REST API operation.</param>
/// <param name="arguments">The arguments for the operation.</param>
/// <param name="options">The operation run options.</param>
/// <returns>The operation headers.</returns>
internal delegate IDictionary<string, string>? RestApiOperationHeadersFactory(RestApiOperation operation, IDictionary<string, object?> arguments, RestApiOperationRunOptions? options);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Net.Http;

namespace Microsoft.SemanticKernel.Plugins.OpenApi;

/// <summary>
/// Represents a delegate for creating a payload for a REST API operation.
/// </summary>
/// <param name="operation">The REST API operation.</param>
/// <param name="arguments">The arguments for the operation.</param>
/// <param name="enableDynamicPayload">
/// 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.
/// </param>
/// <param name="enablePayloadNamespacing">
/// Determines whether payload parameters are resolved from the arguments by
/// full name (parameter name prefixed with the parent property name).
/// </param>
/// <param name="options">The operation run options.</param>
/// <returns>The operation payload.</returns>
internal delegate (object Payload, HttpContent Content)? RestApiOperationPayloadFactory(RestApiOperation operation, IDictionary<string, object?> arguments, bool enableDynamicPayload, bool enablePayloadNamespacing, RestApiOperationRunOptions? options);
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;

namespace Microsoft.SemanticKernel.Plugins.OpenApi;

/// <summary>
/// Represents a delegate for creating a URL for a REST API operation.
/// </summary>
/// <param name="operation">The REST API operation.</param>
/// <param name="arguments">The arguments for the operation.</param>
/// <param name="options">The operation run options.</param>
/// <returns>The operation URL.</returns>
internal delegate Uri? RestApiOperationUrlFactory(RestApiOperation operation, IDictionary<string, object?> arguments, RestApiOperationRunOptions? options);
38 changes: 31 additions & 7 deletions dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ internal sealed class RestApiOperationRunner
/// </summary>
private readonly HttpResponseContentReader? _httpResponseContentReader;

/// <summary>
/// The external URL factory to use if provided, instead of the default one.
/// </summary>
private readonly RestApiOperationUrlFactory? _urlFactory;

/// <summary>
/// The external header factory to use if provided, instead of the default one.
/// </summary>
private readonly RestApiOperationHeadersFactory? _headersFactory;

/// <summary>
/// The external payload factory to use if provided, instead of the default one.
/// </summary>
private readonly RestApiOperationPayloadFactory? _payloadFactory;

/// <summary>
/// Creates an instance of the <see cref="RestApiOperationRunner"/> class.
/// </summary>
Expand All @@ -100,19 +115,28 @@ internal sealed class RestApiOperationRunner
/// <param name="enablePayloadNamespacing">Determines whether payload parameters are resolved from the arguments by
/// full name (parameter name prefixed with the parent property name).</param>
/// <param name="httpResponseContentReader">Custom HTTP response content reader.</param>
/// <param name="urlFactory">The external URL factory to use if provided if provided instead of the default one.</param>
/// <param name="headersFactory">The external headers factory to use if provided instead of the default one.</param>
/// <param name="payloadFactory">The external payload factory to use if provided instead of the default one.</param>
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)
Expand Down Expand Up @@ -145,13 +169,13 @@ public Task<RestApiOperationResponse> 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
Expand Down Expand Up @@ -340,7 +364,7 @@ private async Task<RestApiOperationResponse> ReadContentAndCreateOperationRespon
/// <param name="payloadMetadata">The payload meta-data.</param>
/// <param name="arguments">The payload arguments.</param>
/// <returns>The JSON payload the corresponding HttpContent.</returns>
private (object? Payload, HttpContent Content) BuildJsonPayload(RestApiPayload? payloadMetadata, IDictionary<string, object?> arguments)
private (object Payload, HttpContent Content) BuildJsonPayload(RestApiPayload? payloadMetadata, IDictionary<string, object?> arguments)
{
// Build operation payload dynamically
if (this._enableDynamicPayload)
Expand Down Expand Up @@ -440,7 +464,7 @@ private JsonObject BuildJsonObject(IList<RestApiPayloadProperty> properties, IDi
/// <param name="payloadMetadata">The payload meta-data.</param>
/// <param name="arguments">The payload arguments.</param>
/// <returns>The text payload and corresponding HttpContent.</returns>
private (object? Payload, HttpContent Content) BuildPlainTextPayload(RestApiPayload? payloadMetadata, IDictionary<string, object?> arguments)
private (object Payload, HttpContent Content) BuildPlainTextPayload(RestApiPayload? payloadMetadata, IDictionary<string, object?> arguments)
{
if (!arguments.TryGetValue(RestApiOperation.PayloadArgumentName, out object? argument) || argument is not string payload)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RestApiPayloadProperty> 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<string, RestApiExpectedResponse>(),
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<string, object?> 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<string, string>? CreateHeaders(RestApiOperation operation, IDictionary<string, object?> arguments, RestApiOperationRunOptions? options)
{
createHeadersFactoryCalled = true;
Assert.Same(expectedOperation, operation);
Assert.Same(expectedArguments, arguments);
Assert.Same(expectedOptions, options);

return new Dictionary<string, string>() { ["header-from-factory"] = "value-of-header-from-factory" };
}

(object Payload, HttpContent Content)? CreatePayload(RestApiOperation operation, IDictionary<string, object?> 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<JsonObject>(result.RequestPayload);
Assert.Equal("""{"name":"fake-name-value"}""", ((JsonObject)result.RequestPayload).ToJsonString());
}

public class SchemaTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
Expand Down

0 comments on commit 6d02eef

Please sign in to comment.