diff --git a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs index eac80d47cd..8b015c0a5f 100644 --- a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs +++ b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs @@ -225,6 +225,14 @@ public PluginAuthConfiguration? PluginAuthInformation { get; set; } + + /// + /// When true, should allow generation of adaptive cards + /// + public bool? ShouldGenerateAdaptiveCards + { + get; set; + } } #pragma warning restore CA1056 #pragma warning restore CA2227 diff --git a/src/Kiota.Builder/Kiota.Builder.csproj b/src/Kiota.Builder/Kiota.Builder.csproj index 70463a727c..c0b815e8ce 100644 --- a/src/Kiota.Builder/Kiota.Builder.csproj +++ b/src/Kiota.Builder/Kiota.Builder.csproj @@ -35,6 +35,7 @@ $(NoWarn);CS8785;NU5048;NU5104;CA1724;CA1055;CA1848;CA1308;CA1822 + diff --git a/src/Kiota.Builder/Plugins/AdaptiveCardGenerator.cs b/src/Kiota.Builder/Plugins/AdaptiveCardGenerator.cs new file mode 100644 index 0000000000..56e4cf069b --- /dev/null +++ b/src/Kiota.Builder/Plugins/AdaptiveCardGenerator.cs @@ -0,0 +1,63 @@ +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AdaptiveCards; +using Microsoft.OpenApi.Models; + +namespace Kiota.Builder.Plugins +{ + public class AdaptiveCardGenerator + { + public AdaptiveCardGenerator() + { + } + + public AdaptiveCard GenerateAdaptiveCard(OpenApiOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + var responses = operation.Responses; + var response = responses["200"]; + ArgumentNullException.ThrowIfNull(response); + + var schema = response.Content["application/json"].Schema; + ArgumentNullException.ThrowIfNull(schema); + + var properties = schema.Properties; + ArgumentNullException.ThrowIfNull(properties); + + AdaptiveCard card = new AdaptiveCard(new AdaptiveSchemaVersion(1, 5)); + + foreach (var property in properties) + { + + if (property.Value.Type == "string" && property.Value.Format == "uri") + { + card.Body.Add(new AdaptiveImage() + { + Url = new Uri($"${{{property.Key}}}"), + Size = AdaptiveImageSize.Large, + }); + } + else if (property.Value.Type == "array") + { + card.Body.Add(new AdaptiveTextBlock() + { + Text = $"${{{property.Key}.join(', ')}}", + }); + } + else + { + card.Body.Add(new AdaptiveTextBlock() + { + Text = $"${{{property.Key}, {property.Key}, 'N/A'}}", + }); + } + } + return card; + } + } +} diff --git a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs index 1406aa91c7..bed7057172 100644 --- a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs +++ b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection.Emit; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; @@ -332,7 +333,7 @@ private OpenApiDocument GetDocumentWithTrimmedComponentsAndResponses(OpenApiDocu private PluginManifestDocument GetManifestDocument(string openApiDocumentPath) { - var (runtimes, functions, conversationStarters) = GetRuntimesFunctionsAndConversationStartersFromTree(OAIDocument, Configuration.PluginAuthInformation, TreeNode, openApiDocumentPath, Logger); + var (runtimes, functions, conversationStarters) = GetRuntimesFunctionsAndConversationStartersFromTree(OAIDocument, Configuration, TreeNode, openApiDocumentPath, Logger); var descriptionForHuman = OAIDocument.Info?.Description is string d && !string.IsNullOrEmpty(d) ? d : $"Description for {OAIDocument.Info?.Title}"; var manifestInfo = ExtractInfoFromDocument(OAIDocument.Info); var pluginManifestDocument = new PluginManifestDocument @@ -411,13 +412,14 @@ private sealed record OpenApiManifestInfo( string? PrivacyUrl = null, string ContactEmail = DefaultContactEmail); - private static (OpenApiRuntime[], Function[], ConversationStarter[]) GetRuntimesFunctionsAndConversationStartersFromTree(OpenApiDocument document, PluginAuthConfiguration? authInformation, OpenApiUrlTreeNode currentNode, + private static (OpenApiRuntime[], Function[], ConversationStarter[]) GetRuntimesFunctionsAndConversationStartersFromTree(OpenApiDocument document, GenerationConfiguration configuration, OpenApiUrlTreeNode currentNode, string openApiDocumentPath, ILogger logger) { var runtimes = new List(); var functions = new List(); var conversationStarters = new List(); - var configAuth = authInformation?.ToPluginManifestAuth(); + var configAuth = configuration.PluginAuthInformation?.ToPluginManifestAuth(); + bool shouldGenerateAdaptiveCards = configuration.ShouldGenerateAdaptiveCards ?? false; if (currentNode.PathItems.TryGetValue(Constants.DefaultOpenApiLabel, out var pathItem)) { foreach (var operation in pathItem.Operations.Values.Where(static x => !string.IsNullOrEmpty(x.OperationId))) @@ -443,14 +445,28 @@ private static (OpenApiRuntime[], Function[], ConversationStarter[]) GetRuntimes var summary = operation.Summary.CleanupXMLString(); var description = operation.Description.CleanupXMLString(); - - functions.Add(new Function + var function = new Function { Name = operation.OperationId, Description = !string.IsNullOrEmpty(description) ? description : summary, - States = GetStatesFromOperation(operation), + States = GetStatesFromOperation(operation) + }; + + if (shouldGenerateAdaptiveCards) + { + var generator = new AdaptiveCardGenerator(); + var card = generator.GenerateAdaptiveCard(operation); + function.Capabilities = new FunctionCapabilities + { + ResponseSemantics = new ResponseSemantics + { + StaticTemplate = JsonDocument.Parse(card.ToJson()).RootElement + } + }; + } + + functions.Add(function); - }); conversationStarters.Add(new ConversationStarter { Text = !string.IsNullOrEmpty(summary) ? summary : description @@ -461,7 +477,7 @@ private static (OpenApiRuntime[], Function[], ConversationStarter[]) GetRuntimes foreach (var node in currentNode.Children) { - var (childRuntimes, childFunctions, childConversationStarters) = GetRuntimesFunctionsAndConversationStartersFromTree(document, authInformation, node.Value, openApiDocumentPath, logger); + var (childRuntimes, childFunctions, childConversationStarters) = GetRuntimesFunctionsAndConversationStartersFromTree(document, configuration, node.Value, openApiDocumentPath, logger); runtimes.AddRange(childRuntimes); functions.AddRange(childFunctions); conversationStarters.AddRange(childConversationStarters); diff --git a/tests/Kiota.Builder.Tests/Plugins/AdaptiveCardGeneratorTests.cs b/tests/Kiota.Builder.Tests/Plugins/AdaptiveCardGeneratorTests.cs new file mode 100644 index 0000000000..88f24702f6 --- /dev/null +++ b/tests/Kiota.Builder.Tests/Plugins/AdaptiveCardGeneratorTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using AdaptiveCards; +using Kiota.Builder.Plugins; +using Microsoft.OpenApi.Models; +using Xunit; + +namespace Kiota.Builder.Tests.Plugins +{ + public sealed class AdaptiveCardGeneratorTests + { + [Fact] + public void GenerateAdaptiveCardFromOperation() + { + var sample = new OpenApiOperation + { + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "OK", + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = "object", + Properties = new Dictionary + { + ["name"] = new OpenApiSchema + { + Type = "string" + }, + ["age"] = new OpenApiSchema + { + Type = "number" + } + } + } + } + } + } + } + }; + var expectedCard = new AdaptiveCard(new AdaptiveSchemaVersion(1, 5)); + expectedCard.Body.Add(new AdaptiveTextBlock() + { + Text = "${name, name, 'N/A'}", + }); + expectedCard.Body.Add(new AdaptiveTextBlock() + { + Text = "${age, age, 'N/A'}", + }); + + var generator = new AdaptiveCardGenerator(); + var actualCard = generator.GenerateAdaptiveCard(sample); + Assert.Equal(expectedCard.Body.Count, actualCard.Body.Count); + } + } +} diff --git a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs index 6743f5c63a..d04a62a27d 100644 --- a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs +++ b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AdaptiveCards; using Kiota.Builder.Configuration; using Kiota.Builder.Plugins; using Microsoft.Extensions.Logging; @@ -815,4 +817,227 @@ public async Task MergesAllOfRequestBodyAsync(string content, Action>(); + var openAPIDocumentDS = new OpenApiDocumentDownloadService(_httpClient, _logger); + var outputDirectory = Path.Combine(workingDirectory, "output"); + var generationConfiguration = new GenerationConfiguration + { + OutputPath = outputDirectory, + OpenAPIFilePath = "openapiPath", + PluginTypes = [PluginType.APIPlugin, PluginType.APIManifest, PluginType.OpenAI], + ClientClassName = inputPluginName, + ApiRootUrl = "http://localhost/", //Kiota builder would set this for us + ShouldGenerateAdaptiveCards = true, + }; + var (openAPIDocumentStream, _) = await openAPIDocumentDS.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false); + var openApiDocument = await openAPIDocumentDS.GetDocumentFromStreamAsync(openAPIDocumentStream, generationConfiguration); + KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument); + var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel); + + var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory, _logger); + await pluginsGenerationService.GenerateManifestAsync(); + + Assert.True(File.Exists(Path.Combine(outputDirectory, $"{inputPluginName.ToLower()}-apiplugin.json"))); + Assert.True(File.Exists(Path.Combine(outputDirectory, $"{inputPluginName.ToLower()}-apimanifest.json"))); + + // Validate the v2 plugin + var manifestContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, $"{inputPluginName.ToLower()}-apiplugin.json")); + using var jsonDocument = JsonDocument.Parse(manifestContent); + var resultingManifest = PluginManifestDocument.Load(jsonDocument.RootElement); + + Assert.NotNull(resultingManifest.Document); + Assert.Equal($"{inputPluginName.ToLower()}-openapi.yml", resultingManifest.Document.Runtimes.OfType().First().Spec.Url); + Assert.NotNull(resultingManifest.Document.Functions[1].Capabilities.ResponseSemantics.StaticTemplate); + + var expectedCard = new AdaptiveCard(new AdaptiveSchemaVersion(1, 5)); + expectedCard.Body.Add(new AdaptiveTextBlock + { + Text = "${id, id, 'N/A'}" + }); + + expectedCard.Body.Add(new AdaptiveTextBlock + { + Text = "${displayName, displayName, 'N/A'}" + }); + expectedCard.Body.Add(new AdaptiveTextBlock + { + Text = "${otherNames.join(', ')}" + }); + expectedCard.Body.Add(new AdaptiveTextBlock + { + Text = "${importance, importance, 'N/A'}" + }); + var expectedJson = JsonDocument.Parse(expectedCard.ToJson()).RootElement; + + + var actualJson = resultingManifest.Document.Functions[1].Capabilities.ResponseSemantics.StaticTemplate; + if (actualJson.HasValue) + { + // Properties to compare + List propertiesToCompare = new List { "type", "version", "body" }; + + // Compare properties + foreach (string prop in propertiesToCompare) + { + Assert.Equal(expectedJson.GetProperty(prop).ToString(), actualJson.Value.GetProperty(prop).ToString()); + } + + // Compare the body array separately + var expectedBody = expectedJson.GetProperty("body").ToString(); + var actualBody = actualJson.Value.GetProperty("body").ToString(); + Assert.Equal(expectedBody, actualBody); + } + else + { + Assert.Fail("actualJson is null"); + } + + Assert.Equal(inputPluginName, resultingManifest.Document.Namespace); + } + + [Fact] + public async Task GeneratesManifestWithoutAdaptiveCardAsync() + { + var simpleDescriptionContent = @"openapi: 3.0.0 +info: + title: Microsoft Graph get user API + version: 1.0.0 +servers: + - url: https://graph.microsoft.com/v1.0/ +paths: + /me: + get: + responses: + 200: + description: Success! + content: + application/json: + schema: + $ref: '#/components/schemas/microsoft.graph.user' + /me/get: + get: + responses: + 200: + description: Success! + content: + application/json: + schema: + $ref: '#/components/schemas/microsoft.graph.user' +components: + schemas: + microsoft.graph.user: + type: object + properties: + id: + type: string + displayName: + type: string + otherNames: + type: array + items: + type: string + nullable: true + importance: + $ref: '#/components/schemas/microsoft.graph.importance' + microsoft.graph.importance: + title: importance + enum: + - low + - normal + - high + type: string"; + var workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var simpleDescriptionPath = Path.Combine(workingDirectory) + "description.yaml"; + var inputPluginName = "client"; + await File.WriteAllTextAsync(simpleDescriptionPath, simpleDescriptionContent); + var mockLogger = new Mock>(); + var openAPIDocumentDS = new OpenApiDocumentDownloadService(_httpClient, _logger); + var outputDirectory = Path.Combine(workingDirectory, "output"); + var generationConfiguration = new GenerationConfiguration + { + OutputPath = outputDirectory, + OpenAPIFilePath = "openapiPath", + PluginTypes = [PluginType.APIPlugin, PluginType.APIManifest, PluginType.OpenAI], + ClientClassName = inputPluginName, + ApiRootUrl = "http://localhost/", //Kiota builder would set this for us + }; + var (openAPIDocumentStream, _) = await openAPIDocumentDS.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false); + var openApiDocument = await openAPIDocumentDS.GetDocumentFromStreamAsync(openAPIDocumentStream, generationConfiguration); + KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument); + var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel); + + var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory, _logger); + await pluginsGenerationService.GenerateManifestAsync(); + + Assert.True(File.Exists(Path.Combine(outputDirectory, $"{inputPluginName.ToLower()}-apiplugin.json"))); + Assert.True(File.Exists(Path.Combine(outputDirectory, $"{inputPluginName.ToLower()}-apimanifest.json"))); + + // Validate the v2 plugin + var manifestContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, $"{inputPluginName.ToLower()}-apiplugin.json")); + using var jsonDocument = JsonDocument.Parse(manifestContent); + var resultingManifest = PluginManifestDocument.Load(jsonDocument.RootElement); + + Assert.NotNull(resultingManifest.Document); + Assert.Null(resultingManifest.Document.Functions[1].Capabilities); + } + + #endregion }