Skip to content

Commit 0aa04df

Browse files
.Net: OpenAPI customization (#8182)
### Motivation and Context Today, OpenAPI functionality in SK supports a limited, predefined list of content types: text/plain, application/json, etc. Moreover, the way these content types are read from the "wire" might not be the most efficient for certain scenarios. For example, SK OpenAPI functionality reads JSON as a string and images as a byte array. While this may be sufficient for most cases, there could be situations where this approach is not optimal, and reading both JSON and image content as streams would be preferable. ### Description This PR adds functionality allowing to supply a HTTP content reader to customize HTTP content reading. The reader accepts HTTP request and HTTP response as parameters, so it can access all properties of both to decide on the best way to read content and support content types not supported by default. For example, this custom reader can read JSON as a stream and any content as stream if the `x-stream` heder is provided in HTTP request: ```csharp private static async Task<object?> ReadHttpResponseContentAsync(HttpResponseContentReaderContext context, CancellationToken cancellationToken) { // Read JSON content as a stream rather than as a string, which is the default behavior if (context.Response.Content.Headers.ContentType?.MediaType == "application/json") { return await context.Response.Content.ReadAsStreamAsync(cancellationToken); } // Read any content as a stream if the request contains the "x-stream" header. if (context.Request.Headers.Contains("x-stream")) { return await context.Response.Content.ReadAsStreamAsync(cancellationToken); } // Return null to indicate that any other HTTP content not handled above should be read by the default reader. return null; } // Register the custom HTTP content reader var executionParameters = new OpenApiFunctionExecutionParameters() { HttpResponseContentReader = ReadHttpResponseContentAsync }; // Create OpenAPI plugin var plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("RepairService", "Resources/Plugins/RepairServicePlugin/repair-service.json", executionParameters); ``` Closes: #8070 ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] 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 - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
1 parent 2d31245 commit 0aa04df

9 files changed

Lines changed: 402 additions & 61 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Text.Json;
4+
using System.Text.Json.Serialization;
5+
using Microsoft.SemanticKernel;
6+
using Microsoft.SemanticKernel.Plugins.OpenApi;
7+
8+
namespace Plugins;
9+
10+
/// <summary>
11+
/// Sample shows how to register a custom HTTP content reader for an Open API plugin.
12+
/// </summary>
13+
public sealed class CustomHttpContentReaderForOpenApiPlugin(ITestOutputHelper output) : BaseTest(output)
14+
{
15+
[Fact]
16+
public async Task ShowReadingJsonAsStreamAsync()
17+
{
18+
var kernel = new Kernel();
19+
20+
// Register the custom HTTP content reader
21+
var executionParameters = new OpenApiFunctionExecutionParameters() { HttpResponseContentReader = ReadHttpResponseContentAsync };
22+
23+
// Create OpenAPI plugin
24+
var plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("RepairService", "Resources/Plugins/RepairServicePlugin/repair-service.json", executionParameters);
25+
26+
// Create a repair so it can be read as a stream in the following step
27+
var arguments = new KernelArguments
28+
{
29+
["title"] = "The Case of the Broken Gizmo",
30+
["description"] = "It's broken. Send help!",
31+
["assignedTo"] = "Tech Magician"
32+
};
33+
var createResult = await plugin["createRepair"].InvokeAsync(kernel, arguments);
34+
Console.WriteLine(createResult.ToString());
35+
36+
// List relevant repairs
37+
arguments = new KernelArguments
38+
{
39+
["assignedTo"] = "Tech Magician"
40+
};
41+
var listResult = await plugin["listRepairs"].InvokeAsync(kernel, arguments);
42+
using var reader = new StreamReader((Stream)listResult.GetValue<RestApiOperationResponse>()!.Content!);
43+
var content = await reader.ReadToEndAsync();
44+
var repairs = JsonSerializer.Deserialize<Repair[]>(content);
45+
Console.WriteLine(content);
46+
47+
// Delete the repair
48+
arguments = new KernelArguments
49+
{
50+
["id"] = repairs!.Where(r => r.AssignedTo == "Tech Magician").First().Id.ToString()
51+
};
52+
var deleteResult = await plugin["deleteRepair"].InvokeAsync(kernel, arguments);
53+
Console.WriteLine(deleteResult.ToString());
54+
}
55+
56+
/// <summary>
57+
/// A custom HTTP content reader to change the default behavior of reading HTTP content.
58+
/// </summary>
59+
/// <param name="context">The HTTP response content reader context.</param>
60+
/// <param name="cancellationToken">The cancellation token.</param>
61+
/// <returns>The HTTP response content.</returns>
62+
private static async Task<object?> ReadHttpResponseContentAsync(HttpResponseContentReaderContext context, CancellationToken cancellationToken)
63+
{
64+
// Read JSON content as a stream rather than as a string, which is the default behavior
65+
if (context.Response.Content.Headers.ContentType?.MediaType == "application/json")
66+
{
67+
return await context.Response.Content.ReadAsStreamAsync(cancellationToken);
68+
}
69+
70+
// HTTP request and response properties can be used to decide how to read the content.
71+
// The 'if' operator below is not relevant to the current example and is just for demonstration purposes.
72+
if (context.Request.Headers.Contains("x-stream"))
73+
{
74+
return await context.Response.Content.ReadAsStreamAsync(cancellationToken);
75+
}
76+
77+
// Return null to indicate that any other HTTP content not handled above should be read by the default reader.
78+
return null;
79+
}
80+
81+
private sealed class Repair
82+
{
83+
[JsonPropertyName("id")]
84+
public int? Id { get; set; }
85+
86+
[JsonPropertyName("title")]
87+
public string? Title { get; set; }
88+
89+
[JsonPropertyName("description")]
90+
public string? Description { get; set; }
91+
92+
[JsonPropertyName("assignedTo")]
93+
public string? AssignedTo { get; set; }
94+
95+
[JsonPropertyName("date")]
96+
public string? Date { get; set; }
97+
98+
[JsonPropertyName("image")]
99+
public string? Image { get; set; }
100+
}
101+
}

‎dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ public class OpenApiFunctionExecutionParameters
5959
/// </summary>
6060
public IList<string> OperationsToExclude { get; set; }
6161

62+
/// <summary>
63+
/// A custom HTTP response content reader. It can be useful when the internal reader
64+
/// for a specific content type is either missing, insufficient, or when custom behavior is desired.
65+
/// For instance, the internal reader for "application/json" HTTP content reads the content as a string.
66+
/// This may not be sufficient in cases where the JSON content is large, streamed chunk by chunk, and needs to be accessed
67+
/// as soon as the first chunk is available. To handle such cases, a custom reader can be provided to read the content
68+
/// as a stream rather than as a string.
69+
/// If the custom reader is not provided, or the reader returns null, the internal reader is used.
70+
/// </summary>
71+
public HttpResponseContentReader? HttpResponseContentReader { get; set; }
72+
6273
/// <summary>
6374
/// Initializes a new instance of the <see cref="OpenApiFunctionExecutionParameters"/> class.
6475
/// </summary>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
6+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
7+
8+
/// <summary>
9+
/// Represents a delegate for reading HTTP response content.
10+
/// </summary>
11+
/// <param name="context">The context containing HTTP operation details.</param>
12+
/// <param name="cancellationToken">The cancellation token.</param>
13+
/// <returns>The HTTP response content.</returns>
14+
public delegate Task<object?> HttpResponseContentReader(HttpResponseContentReaderContext context, CancellationToken cancellationToken = default);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Net.Http;
4+
5+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
6+
7+
/// <summary>
8+
/// Represents the context for HTTP response content reader.
9+
/// </summary>
10+
public sealed class HttpResponseContentReaderContext
11+
{
12+
/// <summary>
13+
/// Initializes a new instance of the <see cref="HttpResponseContentReaderContext"/> class.
14+
/// </summary>
15+
/// <param name="request">HTTP request message.</param>
16+
/// <param name="response">HTTP response message.</param>
17+
internal HttpResponseContentReaderContext(HttpRequestMessage request, HttpResponseMessage response)
18+
{
19+
this.Request = request;
20+
this.Response = response;
21+
}
22+
23+
/// <summary>
24+
/// The HTTP request message.
25+
/// </summary>
26+
public HttpRequestMessage Request { get; }
27+
28+
/// <summary>
29+
/// The HTTP response message.
30+
/// </summary>
31+
public HttpResponseMessage Response { get; }
32+
}

‎dotnet/src/Functions/Functions.OpenApi/HttpResponseContentSerializer.cs‎

Lines changed: 0 additions & 13 deletions
This file was deleted.

‎dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ internal static async Task<KernelPlugin> CreateOpenApiPluginAsync(
151151
executionParameters?.AuthCallback,
152152
executionParameters?.UserAgent,
153153
executionParameters?.EnableDynamicPayload ?? true,
154-
executionParameters?.EnablePayloadNamespacing ?? false);
154+
executionParameters?.EnablePayloadNamespacing ?? false,
155+
executionParameters?.HttpResponseContentReader);
155156

156157
var functions = new List<KernelFunction>();
157158
ILogger logger = loggerFactory.CreateLogger(typeof(OpenApiKernelExtensions)) ?? NullLogger.Instance;

0 commit comments

Comments
 (0)