Skip to content

Commit 941ee64

Browse files
.Net: Add factory for customizing OpenAPI plugins responses (#10106)
### Motivation and Context Currently, it's impossible to access the HTTP response and response content headers returned by a REST API requested from OpenAPI plugins. ### Description This PR adds `RestApiOperationResponseFactory`, which can be used to customize the responses of OpenAPI plugins before returning them to the caller. The customization may include modifying the original response by adding response headers, changing the response content, adjusting the schema, or providing a completely new response. Closes: #9986
1 parent 4a70658 commit 941ee64

12 files changed

+428
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Net;
4+
using System.Text;
5+
using Microsoft.SemanticKernel;
6+
using Microsoft.SemanticKernel.Plugins.OpenApi;
7+
8+
namespace Plugins;
9+
10+
/// <summary>
11+
/// Sample shows how to register the <see cref="RestApiOperationResponseFactory"/> to transform existing or create new <see cref="RestApiOperationResponse"/>.
12+
/// </summary>
13+
public sealed class OpenApiPlugin_RestApiOperationResponseFactory(ITestOutputHelper output) : BaseTest(output)
14+
{
15+
private readonly HttpClient _httpClient = new(new StubHttpHandler(InterceptRequestAndCustomizeResponseAsync));
16+
17+
[Fact]
18+
public async Task IncludeResponseHeadersToOperationResponseAsync()
19+
{
20+
Kernel kernel = new();
21+
22+
// Register the operation response factory and the custom HTTP client
23+
OpenApiFunctionExecutionParameters executionParameters = new()
24+
{
25+
RestApiOperationResponseFactory = IncludeHeadersIntoRestApiOperationResponseAsync,
26+
HttpClient = this._httpClient
27+
};
28+
29+
// Create OpenAPI plugin
30+
KernelPlugin plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("RepairService", "Resources/Plugins/RepairServicePlugin/repair-service.json", executionParameters);
31+
32+
// Create arguments for a new repair
33+
KernelArguments arguments = new()
34+
{
35+
["title"] = "The Case of the Broken Gizmo",
36+
["description"] = "It's broken. Send help!",
37+
["assignedTo"] = "Tech Magician"
38+
};
39+
40+
// Create the repair
41+
FunctionResult createResult = await plugin["createRepair"].InvokeAsync(kernel, arguments);
42+
43+
// Get operation response that was modified
44+
RestApiOperationResponse response = createResult.GetValue<RestApiOperationResponse>()!;
45+
46+
// Display the 'repair-id' header value
47+
Console.WriteLine(response.Headers!["repair-id"].First());
48+
}
49+
50+
/// <summary>
51+
/// A custom factory to transform the operation response.
52+
/// </summary>
53+
/// <param name="context">The context for the <see cref="RestApiOperationResponseFactory"/>.</param>
54+
/// <param name="cancellationToken">The cancellation token.</param>
55+
/// <returns>The transformed operation response.</returns>
56+
private static async Task<RestApiOperationResponse> IncludeHeadersIntoRestApiOperationResponseAsync(RestApiOperationResponseFactoryContext context, CancellationToken cancellationToken)
57+
{
58+
// Create the response using the internal factory
59+
RestApiOperationResponse response = await context.InternalFactory(context, cancellationToken);
60+
61+
// Obtain the 'repair-id' header value from the HTTP response and include it in the operation response only for the 'createRepair' operation
62+
if (context.Operation.Id == "createRepair" && context.Response.Headers.TryGetValues("repair-id", out IEnumerable<string>? values))
63+
{
64+
response.Headers ??= new Dictionary<string, IEnumerable<string>>();
65+
response.Headers["repair-id"] = values;
66+
}
67+
68+
// Return the modified response that will be returned to the caller
69+
return response;
70+
}
71+
72+
/// <summary>
73+
/// A custom HTTP handler to intercept HTTP requests and return custom responses.
74+
/// </summary>
75+
/// <param name="request">The original HTTP request.</param>
76+
/// <returns>The custom HTTP response.</returns>
77+
private static async Task<HttpResponseMessage> InterceptRequestAndCustomizeResponseAsync(HttpRequestMessage request)
78+
{
79+
// Return a mock response that includes the 'repair-id' header for the 'createRepair' operation
80+
if (request.RequestUri!.AbsolutePath == "/repairs" && request.Method == HttpMethod.Post)
81+
{
82+
return new HttpResponseMessage(HttpStatusCode.Created)
83+
{
84+
Content = new StringContent("Success", Encoding.UTF8, "application/json"),
85+
Headers =
86+
{
87+
{ "repair-id", "repair-12345" }
88+
}
89+
};
90+
}
91+
92+
return new HttpResponseMessage(HttpStatusCode.NoContent);
93+
}
94+
95+
private sealed class StubHttpHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> requestHandler) : DelegatingHandler()
96+
{
97+
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> _requestHandler = requestHandler;
98+
99+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
100+
{
101+
return await this._requestHandler(request);
102+
}
103+
}
104+
105+
protected override void Dispose(bool disposing)
106+
{
107+
base.Dispose(disposing);
108+
this._httpClient.Dispose();
109+
}
110+
}

dotnet/samples/Concepts/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ dotnet test -l "console;verbosity=detailed" --filter "FullyQualifiedName=ChatCom
169169
- [OpenApiPlugin_Customization](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_Customization.cs)
170170
- [OpenApiPlugin_Filtering](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_Filtering.cs)
171171
- [OpenApiPlugin_Telemetry](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_Telemetry.cs)
172+
- [OpenApiPlugin_RestApiOperationResponseFactory](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/OpenApiPlugin_RestApiOperationResponseFactory.cs)
172173
- [CustomMutablePlugin](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/CustomMutablePlugin.cs)
173174
- [DescribeAllPluginsAndFunctions](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/DescribeAllPluginsAndFunctions.cs)
174175
- [GroundednessChecks](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/Plugins/GroundednessChecks.cs)

dotnet/src/Functions/Functions.Grpc/Functions.Grpc.csproj

+5-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
1212
<Import Project="$(RepoRoot)/dotnet/src/InternalUtilities/src/InternalUtilities.props" />
13-
1413
<PropertyGroup>
1514
<!-- NuGet Package Settings -->
1615
<Title>Semantic Kernel - gRPC Plugins</Title>
@@ -36,4 +35,9 @@
3635
<ProjectReference Include="..\..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
3736
</ItemGroup>
3837

38+
<ItemGroup>
39+
<!-- Exclude utilities that are not used by the project -->
40+
<Compile Remove="$(RepoRoot)/dotnet/src/InternalUtilities/src/Http/HttpResponseStream.cs" Link="%(RecursiveDir)%(Filename)%(Extension)" />
41+
</ItemGroup>
42+
3943
</Project>

dotnet/src/Functions/Functions.Markdown/Functions.Markdown.csproj

+5-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
</PropertyGroup>
1010

1111
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
12-
<Import Project="$(RepoRoot)/dotnet/src/InternalUtilities/src/InternalUtilities.props" />
13-
1412

1513
<PropertyGroup>
1614
<!-- NuGet Package Settings -->
@@ -30,4 +28,9 @@
3028
<ProjectReference Include="..\..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
3129
</ItemGroup>
3230

31+
<ItemGroup>
32+
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/src/Diagnostics/**/*.cs" Link="%(RecursiveDir)%(Filename)%(Extension)" />
33+
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/src/System/AppContextSwitchHelper.cs" Link="%(RecursiveDir)%(Filename)%(Extension)" />
34+
</ItemGroup>
35+
3336
</Project>

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

+9
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ public class OpenApiFunctionExecutionParameters
7979
[Experimental("SKEXP0040")]
8080
public HttpResponseContentReader? HttpResponseContentReader { get; set; }
8181

82+
/// <summary>
83+
/// A custom factory for the <see cref="RestApiOperationResponse"/>.
84+
/// It allows modifications of various aspects of the original response, such as adding response headers,
85+
/// changing response content, adjusting the schema, or providing a completely new response.
86+
/// If a custom factory is not supplied, the internal factory will be used by default.
87+
/// </summary>
88+
[Experimental("SKEXP0040")]
89+
public RestApiOperationResponseFactory? RestApiOperationResponseFactory { get; set; }
90+
8291
/// <summary>
8392
/// A custom REST API parameter filter.
8493
/// </summary>

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ internal static KernelPlugin CreateOpenApiPlugin(
216216
executionParameters?.UserAgent,
217217
executionParameters?.EnableDynamicPayload ?? true,
218218
executionParameters?.EnablePayloadNamespacing ?? false,
219-
executionParameters?.HttpResponseContentReader);
219+
executionParameters?.HttpResponseContentReader,
220+
executionParameters?.RestApiOperationResponseFactory);
220221

221222
var functions = new List<KernelFunction>();
222223
ILogger logger = loggerFactory.CreateLogger(typeof(OpenApiKernelExtensions)) ?? NullLogger.Instance;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Diagnostics.CodeAnalysis;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
8+
9+
/// <summary>
10+
/// Represents a factory for creating instances of the <see cref="RestApiOperationResponse"/>.
11+
/// </summary>
12+
/// <param name="context">The context that contains the operation details.</param>
13+
/// <param name="cancellationToken">The cancellation token used to signal cancellation.</param>
14+
/// <returns>A task that represents the asynchronous operation, containing an instance of <see cref="RestApiOperationResponse"/>.</returns>
15+
[Experimental("SKEXP0040")]
16+
public delegate Task<RestApiOperationResponse> RestApiOperationResponseFactory(RestApiOperationResponseFactoryContext context, CancellationToken cancellationToken = default);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Diagnostics.CodeAnalysis;
4+
using System.Net.Http;
5+
6+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
7+
8+
/// <summary>
9+
/// Represents the context for the <see cref="RestApiOperationResponseFactory"/>."/>
10+
/// </summary>
11+
[Experimental("SKEXP0040")]
12+
public sealed class RestApiOperationResponseFactoryContext
13+
{
14+
/// <summary>
15+
/// Initializes a new instance of the <see cref="RestApiOperationResponseFactoryContext"/> class.
16+
/// </summary>
17+
/// <param name="operation">The REST API operation.</param>
18+
/// <param name="request">The HTTP request message.</param>
19+
/// <param name="response">The HTTP response message.</param>
20+
/// <param name="internalFactory">The internal factory to create instances of the <see cref="RestApiOperationResponse"/>.</param>
21+
internal RestApiOperationResponseFactoryContext(RestApiOperation operation, HttpRequestMessage request, HttpResponseMessage response, RestApiOperationResponseFactory internalFactory)
22+
{
23+
this.InternalFactory = internalFactory;
24+
this.Operation = operation;
25+
this.Request = request;
26+
this.Response = response;
27+
}
28+
29+
/// <summary>
30+
/// The REST API operation.
31+
/// </summary>
32+
public RestApiOperation Operation { get; }
33+
34+
/// <summary>
35+
/// The HTTP request message.
36+
/// </summary>
37+
public HttpRequestMessage Request { get; }
38+
39+
/// <summary>
40+
/// The HTTP response message.
41+
/// </summary>
42+
public HttpResponseMessage Response { get; }
43+
44+
/// <summary>
45+
/// The internal factory to create instances of the <see cref="RestApiOperationResponse"/>.
46+
/// </summary>
47+
public RestApiOperationResponseFactory InternalFactory { get; }
48+
}

dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs

+31-3
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ internal sealed class RestApiOperationRunner
8888
/// </summary>
8989
private readonly HttpResponseContentReader? _httpResponseContentReader;
9090

91+
/// <summary>
92+
/// The external response factory for creating <see cref="RestApiOperationResponse"/>.
93+
/// </summary>
94+
private readonly RestApiOperationResponseFactory? _responseFactory;
95+
9196
/// <summary>
9297
/// The external URL factory to use if provided, instead of the default one.
9398
/// </summary>
@@ -115,6 +120,7 @@ internal sealed class RestApiOperationRunner
115120
/// <param name="enablePayloadNamespacing">Determines whether payload parameters are resolved from the arguments by
116121
/// full name (parameter name prefixed with the parent property name).</param>
117122
/// <param name="httpResponseContentReader">Custom HTTP response content reader.</param>
123+
/// <param name="responseFactory">The external response factory for creating <see cref="RestApiOperationResponse"/>.</param>
118124
/// <param name="urlFactory">The external URL factory to use if provided if provided instead of the default one.</param>
119125
/// <param name="headersFactory">The external headers factory to use if provided instead of the default one.</param>
120126
/// <param name="payloadFactory">The external payload factory to use if provided instead of the default one.</param>
@@ -125,6 +131,7 @@ public RestApiOperationRunner(
125131
bool enableDynamicPayload = false,
126132
bool enablePayloadNamespacing = false,
127133
HttpResponseContentReader? httpResponseContentReader = null,
134+
RestApiOperationResponseFactory? responseFactory = null,
128135
RestApiOperationUrlFactory? urlFactory = null,
129136
RestApiOperationHeadersFactory? headersFactory = null,
130137
RestApiOperationPayloadFactory? payloadFactory = null)
@@ -134,6 +141,7 @@ public RestApiOperationRunner(
134141
this._enableDynamicPayload = enableDynamicPayload;
135142
this._enablePayloadNamespacing = enablePayloadNamespacing;
136143
this._httpResponseContentReader = httpResponseContentReader;
144+
this._responseFactory = responseFactory;
137145
this._urlFactory = urlFactory;
138146
this._headersFactory = headersFactory;
139147
this._payloadFactory = payloadFactory;
@@ -577,11 +585,31 @@ private Uri BuildsOperationUrl(RestApiOperation operation, IDictionary<string, o
577585
/// <returns>The operation response.</returns>
578586
private async Task<RestApiOperationResponse> BuildResponseAsync(RestApiOperation operation, HttpRequestMessage requestMessage, HttpResponseMessage responseMessage, object? payload, CancellationToken cancellationToken)
579587
{
580-
var response = await this.ReadContentAndCreateOperationResponseAsync(requestMessage, responseMessage, payload, cancellationToken).ConfigureAwait(false);
588+
async Task<RestApiOperationResponse> Build(RestApiOperationResponseFactoryContext context, CancellationToken ct)
589+
{
590+
var response = await this.ReadContentAndCreateOperationResponseAsync(context.Request, context.Response, payload, ct).ConfigureAwait(false);
591+
592+
response.ExpectedSchema ??= GetExpectedSchema(context.Operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), context.Response.StatusCode);
593+
594+
return response;
595+
}
581596

582-
response.ExpectedSchema ??= GetExpectedSchema(operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), responseMessage.StatusCode);
597+
// Delegate the response building to the custom response factory if provided.
598+
if (this._responseFactory is not null)
599+
{
600+
var response = await this._responseFactory(new(operation: operation, request: requestMessage, response: responseMessage, internalFactory: Build), cancellationToken).ConfigureAwait(false);
601+
602+
// Handling the case when the content is a stream
603+
if (response.Content is Stream stream and not HttpResponseStream)
604+
{
605+
// Wrap the stream content to capture the HTTP response message, delegating its disposal to the caller.
606+
response.Content = new HttpResponseStream(stream, responseMessage);
607+
}
608+
609+
return response;
610+
}
583611

584-
return response;
612+
return await Build(new(operation: operation, request: requestMessage, response: responseMessage, internalFactory: null!), cancellationToken).ConfigureAwait(false);
585613
}
586614

587615
#endregion

dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs

+37
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Net.Mime;
1010
using System.Text;
1111
using System.Text.Json.Nodes;
12+
using System.Threading;
1213
using System.Threading.Tasks;
1314
using Microsoft.SemanticKernel;
1415
using Microsoft.SemanticKernel.Plugins.OpenApi;
@@ -606,6 +607,42 @@ public async Task ItShouldResolveArgumentsBySanitizedParameterNamesAsync()
606607
Assert.Equal(23.4f, deserializedPayload["float?parameter"]!.GetValue<float>());
607608
}
608609

610+
[Fact]
611+
public async Task ItShouldPropagateRestApiOperationResponseFactoryToRunnerAsync()
612+
{
613+
// Arrange
614+
bool restApiOperationResponseFactoryIsInvoked = false;
615+
616+
async Task<RestApiOperationResponse> RestApiOperationResponseFactory(RestApiOperationResponseFactoryContext context, CancellationToken cancellationToken)
617+
{
618+
restApiOperationResponseFactoryIsInvoked = true;
619+
620+
return await context.InternalFactory(context, cancellationToken);
621+
}
622+
623+
using var messageHandlerStub = new HttpMessageHandlerStub();
624+
using var httpClient = new HttpClient(messageHandlerStub, false);
625+
626+
this._executionParameters.HttpClient = httpClient;
627+
this._executionParameters.RestApiOperationResponseFactory = RestApiOperationResponseFactory;
628+
629+
var openApiPlugins = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("fakePlugin", this._openApiDocument, this._executionParameters);
630+
631+
var kernel = new Kernel();
632+
633+
var arguments = new KernelArguments
634+
{
635+
{ "secret-name", "fake-secret-name" },
636+
{ "api-version", "fake-api-version" }
637+
};
638+
639+
// Act
640+
await kernel.InvokeAsync(openApiPlugins["GetSecret"], arguments);
641+
642+
// Assert
643+
Assert.True(restApiOperationResponseFactoryIsInvoked);
644+
}
645+
609646
/// <summary>
610647
/// Generate theory data for ItAddSecurityMetadataToOperationAsync
611648
/// </summary>

0 commit comments

Comments
 (0)