Skip to content

Commit 1ef0418

Browse files
.Net: Add support for OpenAPI descriptions with server variables (#8291)
### Motivation and Context Closes #8193 ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### 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 😄
1 parent b0a5d21 commit 1ef0418

File tree

12 files changed

+258
-72
lines changed

12 files changed

+258
-72
lines changed

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,6 @@ public static async Task<KernelPlugin> CreatePluginFromApiManifestAsync(
128128
var predicate = OpenApiFilterService.CreatePredicate(null, null, requestUrls, openApiDocument);
129129
var filteredOpenApiDocument = OpenApiFilterService.CreateFilteredDocument(openApiDocument, predicate);
130130

131-
var serverUrl = filteredOpenApiDocument.Servers.FirstOrDefault()?.Url;
132-
133131
var openApiFunctionExecutionParameters = pluginParameters?.FunctionExecutionParameters?.ContainsKey(apiName) == true
134132
? pluginParameters.FunctionExecutionParameters[apiName]
135133
: null;
@@ -145,17 +143,18 @@ public static async Task<KernelPlugin> CreatePluginFromApiManifestAsync(
145143
openApiFunctionExecutionParameters?.EnableDynamicPayload ?? true,
146144
openApiFunctionExecutionParameters?.EnablePayloadNamespacing ?? false);
147145

148-
if (serverUrl is not null)
146+
var server = filteredOpenApiDocument.Servers.FirstOrDefault();
147+
if (server?.Url is not null)
149148
{
150149
foreach (var path in filteredOpenApiDocument.Paths)
151150
{
152-
var operations = OpenApiDocumentParser.CreateRestApiOperations(serverUrl, path.Key, path.Value, null, logger);
151+
var operations = OpenApiDocumentParser.CreateRestApiOperations(server, path.Key, path.Value, null, logger);
153152
foreach (RestApiOperation operation in operations)
154153
{
155154
try
156155
{
157156
logger.LogTrace("Registering Rest function {0}.{1}", pluginName, operation.Id);
158-
functions.Add(OpenApiKernelPluginFactory.CreateRestApiFunction(pluginName, runner, operation, openApiFunctionExecutionParameters, new Uri(serverUrl), loggerFactory));
157+
functions.Add(OpenApiKernelPluginFactory.CreateRestApiFunction(pluginName, runner, operation, openApiFunctionExecutionParameters, new Uri(server.Url), loggerFactory));
159158
}
160159
catch (Exception ex) when (!ex.IsCriticalException())
161160
{

dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ public sealed class RestApiOperation
5050
public HttpMethod Method { get; }
5151

5252
/// <summary>
53-
/// The server URL.
53+
/// The server.
5454
/// </summary>
55-
public Uri? ServerUrl { get; }
55+
public RestApiOperationServer Server { get; }
5656

5757
/// <summary>
5858
/// The operation parameters.
@@ -78,7 +78,7 @@ public sealed class RestApiOperation
7878
/// Creates an instance of a <see cref="RestApiOperation"/> class.
7979
/// </summary>
8080
/// <param name="id">The operation identifier.</param>
81-
/// <param name="serverUrl">The server URL.</param>
81+
/// <param name="server">The server.</param>
8282
/// <param name="path">The operation path.</param>
8383
/// <param name="method">The operation method.</param>
8484
/// <param name="description">The operation description.</param>
@@ -87,7 +87,7 @@ public sealed class RestApiOperation
8787
/// <param name="responses">The operation responses.</param>
8888
public RestApiOperation(
8989
string id,
90-
Uri? serverUrl,
90+
RestApiOperationServer server,
9191
string path,
9292
HttpMethod method,
9393
string description,
@@ -96,7 +96,7 @@ public RestApiOperation(
9696
IDictionary<string, RestApiOperationExpectedResponse>? responses = null)
9797
{
9898
this.Id = id;
99-
this.ServerUrl = serverUrl;
99+
this.Server = server;
100100
this.Path = path;
101101
this.Method = method;
102102
this.Description = description;
@@ -114,7 +114,7 @@ public RestApiOperation(
114114
/// <returns>The operation Url.</returns>
115115
public Uri BuildOperationUrl(IDictionary<string, object?> arguments, Uri? serverUrlOverride = null, Uri? apiHostUrl = null)
116116
{
117-
var serverUrl = this.GetServerUrl(serverUrlOverride, apiHostUrl);
117+
var serverUrl = this.GetServerUrl(serverUrlOverride, apiHostUrl, arguments);
118118

119119
var path = this.BuildPath(this.Path, arguments);
120120

@@ -250,19 +250,40 @@ private string BuildPath(string pathTemplate, IDictionary<string, object?> argum
250250
/// </summary>
251251
/// <param name="serverUrlOverride">Override for REST API operation server url.</param>
252252
/// <param name="apiHostUrl">The URL of REST API host.</param>
253+
/// <param name="arguments">The operation arguments.</param>
253254
/// <returns>The operation server url.</returns>
254-
private Uri GetServerUrl(Uri? serverUrlOverride, Uri? apiHostUrl)
255+
private Uri GetServerUrl(Uri? serverUrlOverride, Uri? apiHostUrl, IDictionary<string, object?> arguments)
255256
{
256257
string serverUrlString;
257258

258259
if (serverUrlOverride is not null)
259260
{
260261
serverUrlString = serverUrlOverride.AbsoluteUri;
261262
}
263+
else if (this.Server.Url is not null)
264+
{
265+
serverUrlString = this.Server.Url;
266+
foreach (var variable in this.Server.Variables)
267+
{
268+
arguments.TryGetValue(variable.Key, out object? value);
269+
string? strValue = value as string;
270+
if (strValue is not null && variable.Value.IsValid(strValue))
271+
{
272+
serverUrlString = serverUrlString.Replace($"{{{variable.Key}}}", strValue);
273+
}
274+
else if (variable.Value.Default is not null)
275+
{
276+
serverUrlString = serverUrlString.Replace($"{{{variable.Key}}}", variable.Value.Default);
277+
}
278+
else
279+
{
280+
throw new KernelException($"No value provided for the '{variable.Key}' server variable of the operation - '{this.Id}'.");
281+
}
282+
}
283+
}
262284
else
263285
{
264286
serverUrlString =
265-
this.ServerUrl?.AbsoluteUri ??
266287
apiHostUrl?.AbsoluteUri ??
267288
throw new InvalidOperationException($"Server url is not defined for operation {this.Id}");
268289
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
5+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
6+
7+
/// <summary>
8+
/// REST API Operation Server.
9+
/// </summary>
10+
public sealed class RestApiOperationServer
11+
{
12+
/// <summary>
13+
/// A URL to the target host. This URL supports Server Variables and MAY be relative,
14+
/// to indicate that the host location is relative to the location where the OpenAPI document is being served.
15+
/// Variable substitutions will be made when a variable is named in {brackets}.
16+
/// </summary>
17+
#pragma warning disable CA1056 // URI-like properties should not be strings
18+
public string? Url { get; }
19+
#pragma warning restore CA1056 // URI-like properties should not be strings
20+
21+
/// <summary>
22+
/// A map between a variable name and its value. The value is used for substitution in the server's URL template.
23+
/// </summary>
24+
public IDictionary<string, RestApiOperationServerVariable> Variables { get; }
25+
26+
/// <summary>
27+
/// Construct a new <see cref="RestApiOperationServer"/> object.
28+
/// </summary>
29+
/// <param name="url">URL to the target host</param>
30+
/// <param name="variables">Substitution variables for the server's URL template</param>
31+
#pragma warning disable CA1054 // URI-like parameters should not be strings
32+
public RestApiOperationServer(string? url = null, IDictionary<string, RestApiOperationServerVariable>? variables = null)
33+
#pragma warning restore CA1054 // URI-like parameters should not be strings
34+
{
35+
this.Url = string.IsNullOrEmpty(url) ? null : url;
36+
this.Variables = variables ?? new Dictionary<string, RestApiOperationServerVariable>();
37+
}
38+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
5+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
6+
7+
/// <summary>
8+
/// REST API Operation Server Variable.
9+
/// </summary>
10+
public sealed class RestApiOperationServerVariable
11+
{
12+
/// <summary>
13+
/// An optional description for the server variable. CommonMark syntax MAY be used for rich text representation.
14+
/// </summary>
15+
public string? Description { get; }
16+
17+
/// <summary>
18+
/// REQUIRED. The default value to use for substitution, and to send, if an alternate value is not supplied.
19+
/// Unlike the Schema Object's default, this value MUST be provided by the consumer.
20+
/// </summary>
21+
public string Default { get; }
22+
23+
/// <summary>
24+
/// An enumeration of string values to be used if the substitution options are from a limited set.
25+
/// </summary>
26+
public List<string>? Enum { get; }
27+
28+
/// <summary>
29+
/// Construct a new <see cref="RestApiOperationServerVariable"/> object.
30+
/// </summary>
31+
/// <param name="defaultValue">The default value to use for substitution.</param>
32+
/// <param name="description">An optional description for the server variable.</param>
33+
/// <param name="enumValues">An enumeration of string values to be used if the substitution options are from a limited set.</param>
34+
public RestApiOperationServerVariable(string defaultValue, string? description = null, List<string>? enumValues = null)
35+
{
36+
this.Default = defaultValue;
37+
this.Description = description;
38+
this.Enum = enumValues;
39+
}
40+
41+
/// <summary>
42+
/// Return true if the value is valid based on the enumeration of string values to be used.
43+
/// </summary>
44+
/// <param name="value">Value to be used as a substitution.</param>
45+
public bool IsValid(string? value)
46+
{
47+
return this.Enum?.Contains(value!) ?? true;
48+
}
49+
}

dotnet/src/Functions/Functions.OpenApi/OpenApi/OpenApiDocumentParser.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,11 @@ private static List<RestApiOperation> ExtractRestApiOperations(OpenApiDocument d
158158
{
159159
var result = new List<RestApiOperation>();
160160

161-
var serverUrl = document.Servers.FirstOrDefault()?.Url;
161+
var server = document.Servers.FirstOrDefault();
162162

163163
foreach (var pathPair in document.Paths)
164164
{
165-
var operations = CreateRestApiOperations(serverUrl, pathPair.Key, pathPair.Value, operationsToExclude, logger);
165+
var operations = CreateRestApiOperations(server, pathPair.Key, pathPair.Value, operationsToExclude, logger);
166166

167167
result.AddRange(operations);
168168
}
@@ -173,15 +173,16 @@ private static List<RestApiOperation> ExtractRestApiOperations(OpenApiDocument d
173173
/// <summary>
174174
/// Creates REST API operation.
175175
/// </summary>
176-
/// <param name="serverUrl">The server url.</param>
176+
/// <param name="server">Rest server.</param>
177177
/// <param name="path">Rest resource path.</param>
178178
/// <param name="pathItem">Rest resource metadata.</param>
179179
/// <param name="operationsToExclude">Optional list of operations not to import, e.g. in case they are not supported</param>
180180
/// <param name="logger">Used to perform logging.</param>
181181
/// <returns>Rest operation.</returns>
182-
internal static List<RestApiOperation> CreateRestApiOperations(string? serverUrl, string path, OpenApiPathItem pathItem, IList<string>? operationsToExclude, ILogger logger)
182+
internal static List<RestApiOperation> CreateRestApiOperations(OpenApiServer? server, string path, OpenApiPathItem pathItem, IList<string>? operationsToExclude, ILogger logger)
183183
{
184184
var operations = new List<RestApiOperation>();
185+
var operationServer = CreateRestApiOperationServer(server);
185186

186187
foreach (var operationPair in pathItem.Operations)
187188
{
@@ -196,7 +197,7 @@ internal static List<RestApiOperation> CreateRestApiOperations(string? serverUrl
196197

197198
var operation = new RestApiOperation(
198199
operationItem.OperationId,
199-
string.IsNullOrEmpty(serverUrl) ? null : new Uri(serverUrl),
200+
operationServer,
200201
path,
201202
new HttpMethod(method),
202203
string.IsNullOrEmpty(operationItem.Description) ? operationItem.Summary : operationItem.Description,
@@ -214,6 +215,16 @@ internal static List<RestApiOperation> CreateRestApiOperations(string? serverUrl
214215
return operations;
215216
}
216217

218+
/// <summary>
219+
/// Build a <see cref="RestApiOperationServer"/> object from the given <see cref="OpenApiServer"/> object.
220+
/// </summary>
221+
/// <param name="server">Represents the server which hosts the REST API.</param>
222+
private static RestApiOperationServer CreateRestApiOperationServer(OpenApiServer? server)
223+
{
224+
var variables = server?.Variables.ToDictionary(item => item.Key, item => new RestApiOperationServerVariable(item.Value.Default, item.Value.Description, item.Value.Enum));
225+
return new(server?.Url, variables);
226+
}
227+
217228
/// <summary>
218229
/// Build a dictionary of extension key value pairs from the given open api extension model, where the key is the extension name
219230
/// and the value is either the actual value in the case of primitive types like string, int, date, etc, or a json string in the

dotnet/src/Functions/Functions.UnitTests/OpenApi/Extensions/RestApiOperationExtensionsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ private static RestApiOperation CreateTestOperation(string method, RestApiOperat
256256
{
257257
return new RestApiOperation(
258258
id: "fake-id",
259-
serverUrl: url,
259+
server: new(url?.AbsoluteUri),
260260
path: "fake-path",
261261
method: new HttpMethod(method),
262262
description: "fake-description",

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public async Task ItCanParsePutOperationMetadataSuccessfullyAsync()
103103
var putOperation = restApi.Operations.Single(o => o.Id == "SetSecret");
104104
Assert.NotNull(putOperation);
105105
Assert.Equal("Sets a secret in a specified key vault.", putOperation.Description);
106-
Assert.Equal("https://my-key-vault.vault.azure.net/", putOperation.ServerUrl?.AbsoluteUri);
106+
Assert.Equal("https://my-key-vault.vault.azure.net", putOperation.Server.Url);
107107
Assert.Equal(HttpMethod.Put, putOperation.Method);
108108
Assert.Equal("/secrets/{secret-name}", putOperation.Path);
109109

@@ -266,7 +266,7 @@ public async Task ItCanWorkWithDocumentsWithoutHostAndSchemaAttributesAsync()
266266
var restApi = await this._sut.ParseAsync(stream);
267267

268268
//Assert
269-
Assert.All(restApi.Operations, (op) => Assert.Null(op.ServerUrl));
269+
Assert.All(restApi.Operations, (op) => Assert.Null(op.Server.Url));
270270
}
271271

272272
[Fact]

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public async Task ItCanParsePutOperationMetadataSuccessfullyAsync()
104104
var putOperation = restApi.Operations.Single(o => o.Id == "SetSecret");
105105
Assert.NotNull(putOperation);
106106
Assert.Equal("Sets a secret in a specified key vault.", putOperation.Description);
107-
Assert.Equal("https://my-key-vault.vault.azure.net/", putOperation.ServerUrl?.AbsoluteUri);
107+
Assert.Equal("https://my-key-vault.vault.azure.net", putOperation.Server.Url);
108108
Assert.Equal(HttpMethod.Put, putOperation.Method);
109109
Assert.Equal("/secrets/{secret-name}", putOperation.Path);
110110

@@ -289,7 +289,7 @@ public async Task ItCanWorkWithDocumentsWithoutServersAttributeAsync()
289289
var restApi = await this._sut.ParseAsync(stream);
290290

291291
//Assert
292-
Assert.All(restApi.Operations, (op) => Assert.Null(op.ServerUrl));
292+
Assert.All(restApi.Operations, (op) => Assert.Null(op.Server.Url));
293293
}
294294

295295
[Fact]
@@ -305,7 +305,7 @@ public async Task ItCanWorkWithDocumentsWithEmptyServersAttributeAsync()
305305
var restApi = await this._sut.ParseAsync(stream);
306306

307307
//Assert
308-
Assert.All(restApi.Operations, (op) => Assert.Null(op.ServerUrl));
308+
Assert.All(restApi.Operations, (op) => Assert.Null(op.Server.Url));
309309
}
310310

311311
[Theory]

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public async Task ItCanParsePutOperationMetadataSuccessfullyAsync()
104104
var putOperation = restApi.Operations.Single(o => o.Id == "SetSecret");
105105
Assert.NotNull(putOperation);
106106
Assert.Equal("Sets a secret in a specified key vault.", putOperation.Description);
107-
Assert.Equal("https://my-key-vault.vault.azure.net/", putOperation.ServerUrl?.AbsoluteUri);
107+
Assert.Equal("https://my-key-vault.vault.azure.net", putOperation.Server.Url);
108108
Assert.Equal(HttpMethod.Put, putOperation.Method);
109109
Assert.Equal("/secrets/{secret-name}", putOperation.Path);
110110

@@ -266,7 +266,7 @@ public async Task ItCanWorkWithDocumentsWithoutServersAttributeAsync()
266266
var restApi = await this._sut.ParseAsync(stream);
267267

268268
//Assert
269-
Assert.All(restApi.Operations, (op) => Assert.Null(op.ServerUrl));
269+
Assert.All(restApi.Operations, (op) => Assert.Null(op.Server.Url));
270270
}
271271

272272
[Fact]
@@ -282,7 +282,7 @@ public async Task ItCanWorkWithDocumentsWithEmptyServersAttributeAsync()
282282
var restApi = await this._sut.ParseAsync(stream);
283283

284284
//Assert
285-
Assert.All(restApi.Operations, (op) => Assert.Null(op.ServerUrl));
285+
Assert.All(restApi.Operations, (op) => Assert.Null(op.Server.Url));
286286
}
287287

288288
[Theory]

0 commit comments

Comments
 (0)