Skip to content

Commit

Permalink
Merge branch 'main' into support-media-type-parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
SergeyMenshykh authored Dec 17, 2024
2 parents 5e1b106 + 4a21254 commit 43a5a64
Show file tree
Hide file tree
Showing 21 changed files with 1,022 additions and 91 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);
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ internal static List<RestApiOperation> CreateRestApiOperations(OpenApiDocument d
path: path,
method: new HttpMethod(method),
description: string.IsNullOrEmpty(operationItem.Description) ? operationItem.Summary : operationItem.Description,
parameters: CreateRestApiOperationParameters(operationItem.OperationId, operationItem.Parameters),
parameters: CreateRestApiOperationParameters(operationItem.OperationId, operationItem.Parameters.Union(pathItem.Parameters, s_parameterNameAndLocationComparer)),
payload: CreateRestApiOperationPayload(operationItem.OperationId, operationItem.RequestBody),
responses: CreateRestApiOperationExpectedResponses(operationItem.Responses).ToDictionary(static item => item.Item1, static item => item.Item2),
securityRequirements: CreateRestApiOperationSecurityRequirements(operationItem.Security)
Expand All @@ -237,6 +237,27 @@ internal static List<RestApiOperation> CreateRestApiOperations(OpenApiDocument d
}
}

private static readonly ParameterNameAndLocationComparer s_parameterNameAndLocationComparer = new();

/// <summary>
/// Compares two <see cref="OpenApiParameter"/> objects by their name and location.
/// </summary>
private sealed class ParameterNameAndLocationComparer : IEqualityComparer<OpenApiParameter>
{
public bool Equals(OpenApiParameter? x, OpenApiParameter? y)
{
if (x is null || y is null)
{
return x == y;
}
return this.GetHashCode(x) == this.GetHashCode(y);
}
public int GetHashCode([DisallowNull] OpenApiParameter obj)
{
return HashCode.Combine(obj.Name, obj.In);
}
}

/// <summary>
/// Build a list of <see cref="RestApiServer"/> objects from the given list of <see cref="OpenApiServer"/> objects.
/// </summary>
Expand Down Expand Up @@ -381,7 +402,7 @@ internal static List<RestApiSecurityRequirement> CreateRestApiOperationSecurityR
/// <param name="operationId">The operation id.</param>
/// <param name="parameters">The OpenAPI parameters.</param>
/// <returns>The parameters.</returns>
private static List<RestApiParameter> CreateRestApiOperationParameters(string operationId, IList<OpenApiParameter> parameters)
private static List<RestApiParameter> CreateRestApiOperationParameters(string operationId, IEnumerable<OpenApiParameter> parameters)
{
var result = new List<RestApiParameter>();

Expand Down
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 @@ -343,7 +367,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 @@ -443,7 +467,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 @@ -5,6 +5,7 @@
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.SemanticKernel;
Expand Down Expand Up @@ -434,6 +435,165 @@ public async Task ItCanFilterOutSpecifiedOperationsAsync()
Assert.Contains(restApiSpec.Operations, o => o.Id == "SetSecret");
Assert.Contains(restApiSpec.Operations, o => o.Id == "GetSecret");
}
[Fact]
public async Task ItCanParsePathItemPathParametersAsync()
{
var document =
"""
{
"swagger": "2.0",
"info": {
"title": "Test API",
"version": "1.0.0"
},
"paths": {
"/items/{itemId}/{format}": {
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"type": "string"
}
],
"get": {
"parameters": [
{
"name": "format",
"in": "path",
"required": true,
"type": "string"
}
],
"summary": "Get an item by ID",
"responses": {
"200": {
"description": "Successful response"
}
}
}
}
}
}
""";

await using var steam = new MemoryStream(Encoding.UTF8.GetBytes(document));
var restApi = await this._sut.ParseAsync(steam);

Assert.NotNull(restApi);
Assert.NotNull(restApi.Operations);
Assert.NotEmpty(restApi.Operations);

var firstOperation = restApi.Operations[0];

Assert.NotNull(firstOperation);
Assert.Equal("Get an item by ID", firstOperation.Description);
Assert.Equal("/items/{itemId}/{format}", firstOperation.Path);

var parameters = firstOperation.GetParameters();
Assert.NotNull(parameters);
Assert.Equal(2, parameters.Count);

var pathParameter = parameters.Single(static p => "itemId".Equals(p.Name, StringComparison.OrdinalIgnoreCase));
Assert.NotNull(pathParameter);
Assert.True(pathParameter.IsRequired);
Assert.Equal(RestApiParameterLocation.Path, pathParameter.Location);
Assert.Null(pathParameter.DefaultValue);
Assert.NotNull(pathParameter.Schema);
Assert.Equal("string", pathParameter.Schema.RootElement.GetProperty("type").GetString());

var formatParameter = parameters.Single(static p => "format".Equals(p.Name, StringComparison.OrdinalIgnoreCase));
Assert.NotNull(formatParameter);
Assert.True(formatParameter.IsRequired);
Assert.Equal(RestApiParameterLocation.Path, formatParameter.Location);
Assert.Null(formatParameter.DefaultValue);
Assert.NotNull(formatParameter.Schema);
Assert.Equal("string", formatParameter.Schema.RootElement.GetProperty("type").GetString());
}

[Fact]
public async Task ItCanParsePathItemPathParametersAndOverridesAsync()
{
var document =
"""
{
"swagger": "2.0",
"info": {
"title": "Test API",
"version": "1.0.0"
},
"paths": {
"/items/{itemId}/{format}": {
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"type": "string"
}
],
"get": {
"parameters": [
{
"name": "format",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "itemId",
"in": "path",
"description": "item ID override",
"required": true,
"type": "string"
}
],
"summary": "Get an item by ID",
"responses": {
"200": {
"description": "Successful response"
}
}
}
}
}
}
""";

await using var steam = new MemoryStream(Encoding.UTF8.GetBytes(document));
var restApi = await this._sut.ParseAsync(steam);

Assert.NotNull(restApi);
Assert.NotNull(restApi.Operations);
Assert.NotEmpty(restApi.Operations);

var firstOperation = restApi.Operations[0];

Assert.NotNull(firstOperation);
Assert.Equal("Get an item by ID", firstOperation.Description);
Assert.Equal("/items/{itemId}/{format}", firstOperation.Path);

var parameters = firstOperation.GetParameters();
Assert.NotNull(parameters);
Assert.Equal(2, parameters.Count);

var pathParameter = parameters.Single(static p => "itemId".Equals(p.Name, StringComparison.OrdinalIgnoreCase));
Assert.NotNull(pathParameter);
Assert.True(pathParameter.IsRequired);
Assert.Equal(RestApiParameterLocation.Path, pathParameter.Location);
Assert.Null(pathParameter.DefaultValue);
Assert.NotNull(pathParameter.Schema);
Assert.Equal("string", pathParameter.Schema.RootElement.GetProperty("type").GetString());
Assert.Equal("item ID override", pathParameter.Description);

var formatParameter = parameters.Single(static p => "format".Equals(p.Name, StringComparison.OrdinalIgnoreCase));
Assert.NotNull(formatParameter);
Assert.True(formatParameter.IsRequired);
Assert.Equal(RestApiParameterLocation.Path, formatParameter.Location);
Assert.Null(formatParameter.DefaultValue);
Assert.NotNull(formatParameter.Schema);
Assert.Equal("string", formatParameter.Schema.RootElement.GetProperty("type").GetString());
}

private static RestApiParameter GetParameterMetadata(IList<RestApiOperation> operations, string operationId,
RestApiParameterLocation location, string name)
Expand Down
Loading

0 comments on commit 43a5a64

Please sign in to comment.