diff --git a/dotnet/src/Functions/Functions.OpenApi/OpenApi/OpenApiDocumentParser.cs b/dotnet/src/Functions/Functions.OpenApi/OpenApi/OpenApiDocumentParser.cs index 67ba2d34e79a..4803d28e1e1b 100644 --- a/dotnet/src/Functions/Functions.OpenApi/OpenApi/OpenApiDocumentParser.cs +++ b/dotnet/src/Functions/Functions.OpenApi/OpenApi/OpenApiDocumentParser.cs @@ -211,7 +211,7 @@ internal static List 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) @@ -237,6 +237,27 @@ internal static List CreateRestApiOperations(OpenApiDocument d } } + private static readonly ParameterNameAndLocationComparer s_parameterNameAndLocationComparer = new(); + + /// + /// Compares two objects by their name and location. + /// + private sealed class ParameterNameAndLocationComparer : IEqualityComparer + { + 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); + } + } + /// /// Build a list of objects from the given list of objects. /// @@ -381,7 +402,7 @@ internal static List CreateRestApiOperationSecurityR /// The operation id. /// The OpenAPI parameters. /// The parameters. - private static List CreateRestApiOperationParameters(string operationId, IList parameters) + private static List CreateRestApiOperationParameters(string operationId, IEnumerable parameters) { var result = new List(); diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs index 625420e2f956..9313297ace66 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs @@ -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; @@ -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 operations, string operationId, RestApiParameterLocation location, string name) diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs index 8728771ac54a..02b3d363ebfb 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; @@ -500,6 +501,176 @@ public async Task ItCanParseDocumentWithMultipleServersAsync() Assert.Equal("https://ppe.my-key-vault.vault.azure.net", restApi.Operations[0].Servers[1].Url); } + [Fact] + public async Task ItCanParsePathItemPathParametersAsync() + { + var document = + """ + { + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/items/{itemId}/{format}": { + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "parameters": [ + { + "name": "format", + "in": "path", + "required": true, + "schema": { + "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 = + """ + { + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/items/{itemId}/{format}": { + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "parameters": [ + { + "name": "format", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemId", + "in": "path", + "description": "item ID override", + "required": true, + "schema": { + "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 MemoryStream ModifyOpenApiDocument(Stream openApiDocument, Action transformer) { var json = JsonSerializer.Deserialize(openApiDocument); diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs index 6455b95dd34b..5fc59c70a8f9 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs @@ -6,6 +6,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; @@ -477,6 +478,176 @@ public async Task ItCanParseDocumentWithMultipleServersAsync() Assert.Equal("https://ppe.my-key-vault.vault.azure.net", restApi.Operations[0].Servers[1].Url); } + [Fact] + public async Task ItCanParsePathItemPathParametersAsync() + {//TODO update the document version when upgrading Microsoft.OpenAPI to v2 + var document = + """ + { + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/items/{itemId}/{format}": { + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "parameters": [ + { + "name": "format", + "in": "path", + "required": true, + "schema": { + "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() + {//TODO update the document version when upgrading Microsoft.OpenAPI to v2 + var document = + """ + { + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/items/{itemId}/{format}": { + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "parameters": [ + { + "name": "format", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemId", + "in": "path", + "description": "item ID override", + "required": true, + "schema": { + "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 MemoryStream ModifyOpenApiDocument(Stream openApiDocument, Action> transformer) { var serializer = new SharpYaml.Serialization.Serializer();