Skip to content

Commit

Permalink
.Net: Add request uri and payload to RestApiOperationResponse (#6082)
Browse files Browse the repository at this point in the history
### Motivation and Context

Closes #6071 

### Description

Add a new `EnablePayloadInResponse` execution parameter which determines
whether payload will be included in the `RestApiOperationResponse`. If
true, the payload will be included in the response. Otherwise the
payload will not be included and `RestApiOperationResponse.Payload` will
be null. `RestApiOperationResponse.IncludesPayload` will be set to true
if the payload is included in the response.

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [ ] The code builds clean without any errors or warnings
- [ ] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [ ] All unit tests pass, and I have added new tests where possible
- [ ] I didn't break anyone 😄
  • Loading branch information
markwallace-microsoft committed May 8, 2024
1 parent 0b43152 commit 431d18b
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 30 deletions.
4 changes: 2 additions & 2 deletions dotnet/src/Functions/Functions.OpenApi/HttpContentFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ namespace Microsoft.SemanticKernel.Plugins.OpenApi;
/// </summary>
/// <param name="payload">The operation payload metadata.</param>
/// <param name="arguments">The operation arguments.</param>
/// <returns>The HTTP content representing the operation payload.</returns>
internal delegate HttpContent HttpContentFactory(RestApiOperationPayload? payload, IDictionary<string, object?> arguments);
/// <returns>The object and HttpContent representing the operation payload.</returns>
internal delegate (object? Payload, HttpContent Content) HttpContentFactory(RestApiOperationPayload? payload, IDictionary<string, object?> arguments);
47 changes: 28 additions & 19 deletions dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,9 @@ internal sealed class RestApiOperationRunner

var headers = operation.BuildHeaders(arguments);

var payload = this.BuildOperationPayload(operation, arguments);
var operationPayload = this.BuildOperationPayload(operation, arguments);

return this.SendAsync(url, operation.Method, headers, payload, operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), cancellationToken);
return this.SendAsync(url, operation.Method, headers, operationPayload.Payload, operationPayload.Content, operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), cancellationToken);
}

#region private
Expand All @@ -140,24 +140,26 @@ internal sealed class RestApiOperationRunner
/// <param name="method">The HTTP request method.</param>
/// <param name="headers">Headers to include into the HTTP request.</param>
/// <param name="payload">HTTP request payload.</param>
/// <param name="requestContent">HTTP request content.</param>
/// <param name="expectedSchemas">The dictionary of expected response schemas.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Response content and content type</returns>
private async Task<RestApiOperationResponse> SendAsync(
Uri url,
HttpMethod method,
IDictionary<string, string>? headers = null,
HttpContent? payload = null,
object? payload = null,
HttpContent? requestContent = null,
IDictionary<string, KernelJsonSchema?>? expectedSchemas = null,
CancellationToken cancellationToken = default)
{
using var requestMessage = new HttpRequestMessage(method, url);

await this._authCallback(requestMessage, cancellationToken).ConfigureAwait(false);

if (payload != null)
if (requestContent != null)
{
requestMessage.Content = payload;
requestMessage.Content = requestContent;
}

requestMessage.Headers.Add("User-Agent", !string.IsNullOrWhiteSpace(this._userAgent)
Expand All @@ -175,7 +177,7 @@ internal sealed class RestApiOperationRunner

using var responseMessage = await this._httpClient.SendWithSuccessCheckAsync(requestMessage, cancellationToken).ConfigureAwait(false);

var response = await SerializeResponseContentAsync(responseMessage.Content).ConfigureAwait(false);
var response = await SerializeResponseContentAsync(requestMessage, payload, responseMessage.Content).ConfigureAwait(false);

response.ExpectedSchema ??= GetExpectedSchema(expectedSchemas, responseMessage.StatusCode);

Expand All @@ -185,9 +187,11 @@ internal sealed class RestApiOperationRunner
/// <summary>
/// Serializes the response content of an HTTP request.
/// </summary>
/// <param name="request">The HttpRequestMessage associated with the HTTP request.</param>
/// <param name="payload">The payload sent in the HTTP request.</param>
/// <param name="content">The HttpContent object containing the response content to be serialized.</param>
/// <returns>The serialized content.</returns>
private static async Task<RestApiOperationResponse> SerializeResponseContentAsync(HttpContent content)
private static async Task<RestApiOperationResponse> SerializeResponseContentAsync(HttpRequestMessage request, object? payload, HttpContent content)
{
var contentType = content.Headers.ContentType;

Expand Down Expand Up @@ -215,20 +219,25 @@ private static async Task<RestApiOperationResponse> SerializeResponseContentAsyn
// Serialize response content and return it
var serializedContent = await serializer.Invoke(content).ConfigureAwait(false);

return new RestApiOperationResponse(serializedContent, contentType!.ToString());
return new RestApiOperationResponse(serializedContent, contentType!.ToString())
{
RequestMethod = request.Method.Method,
RequestUri = request.RequestUri,
RequestPayload = payload,
};
}

/// <summary>
/// Builds operation payload.
/// </summary>
/// <param name="operation">The operation.</param>
/// <param name="arguments">The payload arguments.</param>
/// <returns>The HttpContent representing the payload.</returns>
private HttpContent? BuildOperationPayload(RestApiOperation operation, IDictionary<string, object?> arguments)
/// <param name="arguments">The operation payload arguments.</param>
/// <returns>The raw operation payload and the corresponding HttpContent.</returns>
private (object? Payload, HttpContent? Content) BuildOperationPayload(RestApiOperation operation, IDictionary<string, object?> arguments)
{
if (operation.Payload is null && !arguments.ContainsKey(RestApiOperation.PayloadArgumentName))
{
return null;
return (null, null);
}

var mediaType = operation.Payload?.MediaType;
Expand All @@ -255,8 +264,8 @@ private static async Task<RestApiOperationResponse> SerializeResponseContentAsyn
/// </summary>
/// <param name="payloadMetadata">The payload meta-data.</param>
/// <param name="arguments">The payload arguments.</param>
/// <returns>The HttpContent representing the payload.</returns>
private HttpContent BuildJsonPayload(RestApiOperationPayload? payloadMetadata, IDictionary<string, object?> arguments)
/// <returns>The JSON payload the corresponding HttpContent.</returns>
private (object? Payload, HttpContent Content) BuildJsonPayload(RestApiOperationPayload? payloadMetadata, IDictionary<string, object?> arguments)
{
// Build operation payload dynamically
if (this._enableDynamicPayload)
Expand All @@ -268,7 +277,7 @@ private HttpContent BuildJsonPayload(RestApiOperationPayload? payloadMetadata, I

var payload = this.BuildJsonObject(payloadMetadata.Properties, arguments);

return new StringContent(payload.ToJsonString(), Encoding.UTF8, MediaTypeApplicationJson);
return (payload, new StringContent(payload.ToJsonString(), Encoding.UTF8, MediaTypeApplicationJson));
}

// Get operation payload content from the 'payload' argument if dynamic payload building is not required.
Expand All @@ -277,7 +286,7 @@ private HttpContent BuildJsonPayload(RestApiOperationPayload? payloadMetadata, I
throw new KernelException($"No payload is provided by the argument '{RestApiOperation.PayloadArgumentName}'.");
}

return new StringContent(content, Encoding.UTF8, MediaTypeApplicationJson);
return (content, new StringContent(content, Encoding.UTF8, MediaTypeApplicationJson));
}

/// <summary>
Expand Down Expand Up @@ -348,15 +357,15 @@ private JsonObject BuildJsonObject(IList<RestApiOperationPayloadProperty> proper
/// </summary>
/// <param name="payloadMetadata">The payload meta-data.</param>
/// <param name="arguments">The payload arguments.</param>
/// <returns>The HttpContent representing the payload.</returns>
private HttpContent BuildPlainTextPayload(RestApiOperationPayload? payloadMetadata, IDictionary<string, object?> arguments)
/// <returns>The text payload and corresponding HttpContent.</returns>
private (object? Payload, HttpContent Content) BuildPlainTextPayload(RestApiOperationPayload? payloadMetadata, IDictionary<string, object?> arguments)
{
if (!arguments.TryGetValue(RestApiOperation.PayloadArgumentName, out object? argument) || argument is not string payload)
{
throw new KernelException($"No argument is found for the '{RestApiOperation.PayloadArgumentName}' payload content.");
}

return new StringContent(payload, Encoding.UTF8, MediaTypeTextPlain);
return (payload, new StringContent(payload, Encoding.UTF8, MediaTypeTextPlain));
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,54 @@ public async Task ItShouldThrowExceptionForUnsupportedContentTypeAsync()
await Assert.ThrowsAsync<KernelException>(() => sut.RunAsync(operation, arguments));
}

[Fact]
public async Task ItShouldReturnRequestUriAndContentAsync()
{
// Arrange
this._httpMessageHandlerStub.ResponseToReturn.Content = new StringContent("fake-content", Encoding.UTF8, MediaTypeNames.Application.Json);

List<RestApiOperationPayloadProperty> payloadProperties =
[
new("name", "string", true, []),
new("attributes", "object", false,
[
new("enabled", "boolean", false, []),
])
];

var payload = new RestApiOperationPayload(MediaTypeNames.Application.Json, payloadProperties);

var operation = new RestApiOperation(
"fake-id",
new Uri("https://fake-random-test-host"),
"fake-path",
HttpMethod.Post,
"fake-description",
[],
payload
);

var arguments = new KernelArguments
{
{ "name", "fake-name-value" },
{ "enabled", true }
};

var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: true);

// Act
var result = await sut.RunAsync(operation, arguments);

// Assert
Assert.NotNull(result.RequestMethod);
Assert.Equal(HttpMethod.Post.Method, result.RequestMethod);
Assert.NotNull(result.RequestUri);
Assert.Equal("https://fake-random-test-host/fake-path", result.RequestUri.AbsoluteUri);
Assert.NotNull(result.RequestPayload);
Assert.IsType<JsonObject>(result.RequestPayload);
Assert.Equal("{\"name\":\"fake-name-value\",\"attributes\":{\"enabled\":true}}", ((JsonObject)result.RequestPayload).ToJsonString());
}

public class SchemaTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
Expand Down
46 changes: 37 additions & 9 deletions dotnet/src/IntegrationTests/Plugins/RepairServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Plugins.OpenApi;
Expand All @@ -17,7 +19,6 @@ public async Task RepairServicePluginAsync()
using var stream = System.IO.File.OpenRead("Plugins/repair-service.json");
using HttpClient httpClient = new();

//note that this plugin is not compliant according to the underlying validator in SK
var plugin = await kernel.ImportPluginFromOpenApiAsync(
"RepairService",
stream,
Expand All @@ -28,35 +29,62 @@ public async Task RepairServicePluginAsync()
["payload"] = """{ "title": "Engine oil change", "description": "Need to drain the old engine oil and replace it with fresh oil.", "assignedTo": "", "date": "", "image": "" }"""
};

// Act
// Create Repair
var result = await plugin["createRepair"].InvokeAsync(kernel, arguments);

// Assert
Assert.NotNull(result);
Assert.Equal("New repair created", result.ToString());

// List All Repairs
result = await plugin["listRepairs"].InvokeAsync(kernel, arguments);

Assert.NotNull(result);
var repairs = JsonSerializer.Deserialize<Repair[]>(result.ToString());
Assert.True(repairs?.Length > 0);

var id = repairs[repairs.Length - 1].Id;

// Update Repair
arguments = new KernelArguments
{
["payload"] = """{ "id": 1, "assignedTo": "Karin Blair", "date": "2024-04-16", "image": "https://www.howmuchisit.org/wp-content/uploads/2011/01/oil-change.jpg" }"""
["payload"] = $"{{ \"id\": {id}, \"assignedTo\": \"Karin Blair\", \"date\": \"2024-04-16\", \"image\": \"https://www.howmuchisit.org/wp-content/uploads/2011/01/oil-change.jpg\" }}"
};

// Act
result = await plugin["updateRepair"].InvokeAsync(kernel, arguments);

// Assert
Assert.NotNull(result);
Assert.Equal("Repair updated", result.ToString());

// Delete Repair
arguments = new KernelArguments
{
["payload"] = """{ "id": 1 }"""
["payload"] = $"{{ \"id\": {id} }}"
};

// Act
result = await plugin["deleteRepair"].InvokeAsync(kernel, arguments);

// Assert
Assert.NotNull(result);
Assert.Equal("Repair deleted", result.ToString());
}

public class Repair
{
[JsonPropertyName("id")]
public int? Id { get; set; }

[JsonPropertyName("title")]
public string? Title { get; set; }

[JsonPropertyName("description")]
public string? description { get; set; }

[JsonPropertyName("assignedTo")]
public string? assignedTo { get; set; }

[JsonPropertyName("date")]
public string? Date { get; set; }

[JsonPropertyName("image")]
public string? Image { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.ComponentModel;

namespace Microsoft.SemanticKernel;
Expand All @@ -25,6 +26,21 @@ public sealed class RestApiOperationResponse
/// </summary>
public KernelJsonSchema? ExpectedSchema { get; set; }

/// <summary>
/// Gets the method used for the HTTP request.
/// </summary>
public string? RequestMethod { get; init; }

/// <summary>
/// Gets the System.Uri used for the HTTP request.
/// </summary>
public Uri? RequestUri { get; init; }

/// <summary>
/// Gets the payload sent in the request.
/// </summary>
public object? RequestPayload { get; init; }

/// <summary>
/// Initializes a new instance of the <see cref="RestApiOperationResponse"/> class.
/// </summary>
Expand Down

0 comments on commit 431d18b

Please sign in to comment.