diff --git a/.vscode/launch.json b/.vscode/launch.json index 06fa4a05f2..cdb61f49af 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -196,7 +196,10 @@ "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", - "args": ["search", "microsoft"], + "args": [ + "search", + "microsoft" + ], "cwd": "${workspaceFolder}/src/kiota", "console": "internalConsole", "stopAtEntry": false @@ -207,7 +210,10 @@ "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", - "args": ["search", "test"], + "args": [ + "search", + "test" + ], "cwd": "${workspaceFolder}/src/kiota", "console": "internalConsole", "stopAtEntry": false @@ -249,7 +255,11 @@ "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", - "args": ["info", "-l", "CSharp"], + "args": [ + "info", + "-l", + "CSharp" + ], "cwd": "${workspaceFolder}/src/kiota", "console": "internalConsole", "stopAtEntry": false @@ -260,7 +270,11 @@ "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", - "args": ["update", "-o", "${workspaceFolder}/samples"], + "args": [ + "update", + "-o", + "${workspaceFolder}/samples" + ], "cwd": "${workspaceFolder}/src/kiota", "console": "internalConsole", "stopAtEntry": false @@ -271,7 +285,10 @@ "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", - "args": ["workspace", "migrate"], + "args": [ + "workspace", + "migrate" + ], "cwd": "${workspaceFolder}/samples/msgraph-mail/dotnet", "console": "internalConsole", "stopAtEntry": false, @@ -285,7 +302,10 @@ "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", - "args": ["client", "generate"], + "args": [ + "client", + "generate" + ], "cwd": "${workspaceFolder}/samples/msgraph-mail/dotnet", "console": "internalConsole", "stopAtEntry": false, @@ -314,13 +334,44 @@ "KIOTA_CONFIG_PREVIEW": "true" } }, + { + "name": "Launch Plugin Add", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", + "args": [ + "plugin", + "add", + "--plugin-name", + "MicrosoftGraph", + "-d", + "https://raw.githubusercontent.com/microsoftgraph/msgraph-sdk-powershell/dev/openApiDocs/v1.0/Mail.yml", + "-i", + "**/messages", + "--type", + "ApiManifest", + "--type", + "microsoft" + ], + "cwd": "${workspaceFolder}/samples/msgraph-mail/dotnet", + "console": "internalConsole", + "stopAtEntry": false, + "env": { + "KIOTA_CONFIG_PREVIEW": "true" + } + }, { "name": "Launch Login (github - device)", "type": "coreclr", "request": "launch", "preLaunchTask": "build", "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", - "args": ["login", "github", "device"], + "args": [ + "login", + "github", + "device" + ], "cwd": "${workspaceFolder}/src/kiota", "console": "internalConsole", "stopAtEntry": false @@ -332,4 +383,4 @@ "processId": "${command:pickProcess}" } ] -} +} \ No newline at end of file diff --git a/src/Kiota.Builder/Configuration/ClientOperation.cs b/src/Kiota.Builder/Configuration/ConsumerOperation.cs similarity index 75% rename from src/Kiota.Builder/Configuration/ClientOperation.cs rename to src/Kiota.Builder/Configuration/ConsumerOperation.cs index 3d7770a738..0973975f29 100644 --- a/src/Kiota.Builder/Configuration/ClientOperation.cs +++ b/src/Kiota.Builder/Configuration/ConsumerOperation.cs @@ -1,5 +1,5 @@ namespace Kiota.Builder.Configuration; -public enum ClientOperation +public enum ConsumerOperation { Add, Edit, diff --git a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs index 0bb711394c..4a22c24b8a 100644 --- a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs +++ b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs @@ -9,7 +9,6 @@ namespace Kiota.Builder.Configuration; #pragma warning disable CA2227 -#pragma warning disable CA1002 #pragma warning disable CA1056 public class GenerationConfiguration : ICloneable { @@ -30,7 +29,7 @@ public bool SkipGeneration { get; set; } - public ClientOperation? Operation + public ConsumerOperation? Operation { get; set; } @@ -46,6 +45,7 @@ public string ModelsNamespaceName get => $"{ClientNamespaceName}{NamespaceNameSeparator}{ModelsNamespaceSegmentName}"; } public GenerationLanguage Language { get; set; } = GenerationLanguage.CSharp; + public HashSet PluginTypes { get; set; } = []; public string? ApiRootUrl { get; set; @@ -150,6 +150,7 @@ public object Clone() SkipGeneration = SkipGeneration, Operation = Operation, PatternsOverride = new(PatternsOverride ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase), + PluginTypes = new(PluginTypes ?? Enumerable.Empty()), }; } private static readonly StringIEnumerableDeepComparer comparer = new(); @@ -185,7 +186,7 @@ public ApiDependency ToApiDependency(string configurationHash, Dictionary PluginTypes.Count != 0; } #pragma warning restore CA1056 -#pragma warning restore CA1002 #pragma warning restore CA2227 diff --git a/src/Kiota.Builder/Kiota.Builder.csproj b/src/Kiota.Builder/Kiota.Builder.csproj index 531683f584..e8aebabd07 100644 --- a/src/Kiota.Builder/Kiota.Builder.csproj +++ b/src/Kiota.Builder/Kiota.Builder.csproj @@ -47,9 +47,11 @@ + - + @@ -57,4 +59,4 @@ - + \ No newline at end of file diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 1f8499d3d6..5b7c2b6f0f 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -22,6 +22,7 @@ using Kiota.Builder.Logging; using Kiota.Builder.Manifest; using Kiota.Builder.OpenApiExtensions; +using Kiota.Builder.Plugins; using Kiota.Builder.Refiners; using Kiota.Builder.WorkspaceManagement; using Kiota.Builder.Writers; @@ -221,6 +222,27 @@ private void UpdateConfigurationFromOpenApiDocument() return kiotaExt.LanguagesInformation; return null; } + /// + /// Generates the API plugins from the OpenAPI document + /// + /// The cancellation token + /// Whether the generated plugin was updated or not + public async Task GeneratePluginAsync(CancellationToken cancellationToken) + { + return await GenerateConsumerAsync(async (sw, stepId, openApiTree, CancellationToken) => + { + if (config.PluginTypes.Contains(PluginType.OpenAI)) + throw new NotImplementedException("The OpenAI plugin type is not supported for generation"); + if (openApiDocument is null || openApiTree is null) + throw new InvalidOperationException("The OpenAPI document and the URL tree must be loaded before generating the plugins"); + // generate plugin + sw.Start(); + var pluginsService = new PluginsGenerationService(openApiDocument, openApiTree, config); + await pluginsService.GenerateManifestAsync(cancellationToken).ConfigureAwait(false); + StopLogAndReset(sw, $"step {++stepId} - generate plugin - took"); + return stepId; + }, cancellationToken).ConfigureAwait(false); + } /// /// Generates the code from the OpenAPI document @@ -228,12 +250,33 @@ private void UpdateConfigurationFromOpenApiDocument() /// The cancellation token /// Whether the generated code was updated or not public async Task GenerateClientAsync(CancellationToken cancellationToken) + { + return await GenerateConsumerAsync(async (sw, stepId, openApiTree, CancellationToken) => + { + // Create Source Model + sw.Start(); + var generatedCode = CreateSourceModel(openApiTree); + StopLogAndReset(sw, $"step {++stepId} - create source model - took"); + + // RefineByLanguage + sw.Start(); + await ApplyLanguageRefinement(config, generatedCode, cancellationToken).ConfigureAwait(false); + StopLogAndReset(sw, $"step {++stepId} - refine by language - took"); + + // Write language source + sw.Start(); + await CreateLanguageSourceFilesAsync(config.Language, generatedCode, cancellationToken).ConfigureAwait(false); + StopLogAndReset(sw, $"step {++stepId} - writing files - took"); + return stepId; + }, cancellationToken).ConfigureAwait(false); + } + private async Task GenerateConsumerAsync(Func> innerGenerationSteps, CancellationToken cancellationToken) { var sw = new Stopwatch(); // Read input stream var inputPath = config.OpenAPIFilePath; - if (config.Operation is ClientOperation.Add && await workspaceManagementService.IsClientPresent(config.ClientClassName, cancellationToken).ConfigureAwait(false)) + if (config.Operation is ConsumerOperation.Add && await workspaceManagementService.IsConsumerPresent(config.ClientClassName, cancellationToken).ConfigureAwait(false)) throw new InvalidOperationException($"The client {config.ClientClassName} already exists in the workspace"); try @@ -252,27 +295,14 @@ public async Task GenerateClientAsync(CancellationToken cancellationToken) if (shouldGenerate) { - // Create Source Model - sw.Start(); - var generatedCode = CreateSourceModel(openApiTree); - StopLogAndReset(sw, $"step {++stepId} - create source model - took"); - - // RefineByLanguage - sw.Start(); - await ApplyLanguageRefinement(config, generatedCode, cancellationToken).ConfigureAwait(false); - StopLogAndReset(sw, $"step {++stepId} - refine by language - took"); - - // Write language source - sw.Start(); - await CreateLanguageSourceFilesAsync(config.Language, generatedCode, cancellationToken).ConfigureAwait(false); - StopLogAndReset(sw, $"step {++stepId} - writing files - took"); + stepId = await innerGenerationSteps(sw, stepId, openApiTree, cancellationToken).ConfigureAwait(false); await FinalizeWorkspaceAsync(sw, stepId, openApiTree, inputPath, cancellationToken).ConfigureAwait(false); } else { logger.LogInformation("No changes detected, skipping generation"); - if (config.Operation is ClientOperation.Add or ClientOperation.Edit && config.SkipGeneration) + if (config.Operation is ConsumerOperation.Add or ConsumerOperation.Edit && config.SkipGeneration) { await FinalizeWorkspaceAsync(sw, stepId, openApiTree, inputPath, cancellationToken).ConfigureAwait(false); } diff --git a/src/Kiota.Builder/OpenApiDocumentDownloadService.cs b/src/Kiota.Builder/OpenApiDocumentDownloadService.cs index 17d06e1a6e..b4dad81576 100644 --- a/src/Kiota.Builder/OpenApiDocumentDownloadService.cs +++ b/src/Kiota.Builder/OpenApiDocumentDownloadService.cs @@ -46,7 +46,7 @@ public OpenApiDocumentDownloadService(HttpClient httpClient, ILogger logger) Stream input; var isDescriptionFromWorkspaceCopy = false; if (useKiotaConfig && - config.Operation is ClientOperation.Edit or ClientOperation.Add && + config.Operation is ConsumerOperation.Edit or ConsumerOperation.Add && workspaceManagementService is not null && await workspaceManagementService.GetDescriptionCopyAsync(config.ClientClassName, inputPath, config.CleanOutput, cancellationToken).ConfigureAwait(false) is { } descriptionStream) { @@ -114,6 +114,12 @@ ex is SecurityException || }; settings.AddMicrosoftExtensionParsers(); settings.ExtensionParsers.TryAdd(OpenApiKiotaExtension.Name, static (i, _) => OpenApiKiotaExtension.Parse(i)); + settings.ExtensionParsers.TryAdd(OpenApiDescriptionForModelExtension.Name, static (i, _) => OpenApiDescriptionForModelExtension.Parse(i)); + settings.ExtensionParsers.TryAdd(OpenApiLogoExtension.Name, static (i, _) => OpenApiLogoExtension.Parse(i)); + settings.ExtensionParsers.TryAdd(OpenApiPrivacyPolicyUrlExtension.Name, static (i, _) => OpenApiPrivacyPolicyUrlExtension.Parse(i)); + settings.ExtensionParsers.TryAdd(OpenApiLegalInfoUrlExtension.Name, static (i, _) => OpenApiLegalInfoUrlExtension.Parse(i)); + settings.ExtensionParsers.TryAdd(OpenApiAiReasoningInstructionsExtension.Name, static (i, _) => OpenApiAiReasoningInstructionsExtension.Parse(i)); + settings.ExtensionParsers.TryAdd(OpenApiAiRespondingInstructionsExtension.Name, static (i, _) => OpenApiAiRespondingInstructionsExtension.Parse(i)); try { var rawUri = config.OpenAPIFilePath.TrimEnd(KiotaBuilder.ForwardSlash); diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiAiReasoningInstructionsExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiAiReasoningInstructionsExtension.cs new file mode 100644 index 0000000000..f690e1df81 --- /dev/null +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiAiReasoningInstructionsExtension.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Writers; + +namespace Kiota.Builder.OpenApiExtensions; + +public class OpenApiAiReasoningInstructionsExtension : IOpenApiExtension +{ + public static string Name => "x-ai-reasoning-instructions"; +#pragma warning disable CA1002 // Do not expose generic lists + public List ReasoningInstructions { get; init; } = []; +#pragma warning restore CA1002 // Do not expose generic lists + public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(writer); + if (ReasoningInstructions != null && + ReasoningInstructions.Count != 0) + { + writer.WriteStartArray(); + foreach (var instruction in ReasoningInstructions) + { + writer.WriteValue(instruction); + } + writer.WriteEndArray(); + } + } + public static OpenApiAiReasoningInstructionsExtension Parse(IOpenApiAny source) + { + if (source is not OpenApiArray rawArray) throw new ArgumentOutOfRangeException(nameof(source)); + var result = new OpenApiAiReasoningInstructionsExtension(); + result.ReasoningInstructions.AddRange(rawArray.OfType().Select(x => x.Value)); + return result; + } +} diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiAiRespondingInstructionsExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiAiRespondingInstructionsExtension.cs new file mode 100644 index 0000000000..b8137ec225 --- /dev/null +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiAiRespondingInstructionsExtension.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Writers; + +namespace Kiota.Builder.OpenApiExtensions; + +public class OpenApiAiRespondingInstructionsExtension : IOpenApiExtension +{ + public static string Name => "x-ai-responding-instructions"; +#pragma warning disable CA1002 // Do not expose generic lists + public List RespondingInstructions { get; init; } = []; +#pragma warning restore CA1002 // Do not expose generic lists + public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(writer); + if (RespondingInstructions != null && + RespondingInstructions.Count != 0) + { + writer.WriteStartArray(); + foreach (var instruction in RespondingInstructions) + { + writer.WriteValue(instruction); + } + writer.WriteEndArray(); + } + } + public static OpenApiAiRespondingInstructionsExtension Parse(IOpenApiAny source) + { + if (source is not OpenApiArray rawArray) throw new ArgumentOutOfRangeException(nameof(source)); + var result = new OpenApiAiRespondingInstructionsExtension(); + result.RespondingInstructions.AddRange(rawArray.OfType().Select(x => x.Value)); + return result; + } +} diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiDescriptionForModelExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiDescriptionForModelExtension.cs new file mode 100644 index 0000000000..ba198df7c9 --- /dev/null +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiDescriptionForModelExtension.cs @@ -0,0 +1,20 @@ +using Microsoft.OpenApi.Any; + +namespace Kiota.Builder.OpenApiExtensions; + +public class OpenApiDescriptionForModelExtension : OpenApiSimpleStringExtension +{ + public static string Name => "x-ai-description"; + public string? Description + { + get; set; + } + protected override string? ValueSelector => Description; + public static OpenApiDescriptionForModelExtension Parse(IOpenApiAny source) + { + return new OpenApiDescriptionForModelExtension + { + Description = ParseString(source) + }; + } +} diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiKiotaExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiKiotaExtension.cs index b6ef45f507..80a9149f2f 100644 --- a/src/Kiota.Builder/OpenApiExtensions/OpenApiKiotaExtension.cs +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiKiotaExtension.cs @@ -1,6 +1,4 @@ - - -using System; +using System; using System.Linq; using Kiota.Builder.Configuration; using Kiota.Builder.Extensions; diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiLegalInfoUrlExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiLegalInfoUrlExtension.cs new file mode 100644 index 0000000000..3f042fc70c --- /dev/null +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiLegalInfoUrlExtension.cs @@ -0,0 +1,20 @@ +using Microsoft.OpenApi.Any; + +namespace Kiota.Builder.OpenApiExtensions; + +public class OpenApiLegalInfoUrlExtension : OpenApiSimpleStringExtension +{ + public static string Name => "x-legal-info-url"; + public string? Legal + { + get; set; + } + protected override string? ValueSelector => Legal; + public static OpenApiLegalInfoUrlExtension Parse(IOpenApiAny source) + { + return new OpenApiLegalInfoUrlExtension + { + Legal = ParseString(source) + }; + } +} diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiLogoExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiLogoExtension.cs new file mode 100644 index 0000000000..edd5a2daf5 --- /dev/null +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiLogoExtension.cs @@ -0,0 +1,41 @@ +using System; +using Kiota.Builder.Extensions; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Writers; + +namespace Kiota.Builder.OpenApiExtensions; + +public class OpenApiLogoExtension : IOpenApiExtension +{ + public static string Name => "x-logo"; +#pragma warning disable CA1056 + public string? Url +#pragma warning restore CA1056 + { + get; set; + } + public static OpenApiLogoExtension Parse(IOpenApiAny source) + { + if (source is not OpenApiObject rawObject) throw new ArgumentOutOfRangeException(nameof(source)); + var extension = new OpenApiLogoExtension(); + if (rawObject.TryGetValue(nameof(Url).ToFirstCharacterLowerCase(), out var url) && url is OpenApiString urlValue) + { + extension.Url = urlValue.Value; + } + return extension; + } + + public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(writer); + if (!string.IsNullOrEmpty(Url)) + { + writer.WriteStartObject(); + writer.WritePropertyName(nameof(Url).ToFirstCharacterLowerCase()); + writer.WriteValue(Url); + writer.WriteEndObject(); + } + } +} diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiPrivacyPolicyUrlExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiPrivacyPolicyUrlExtension.cs new file mode 100644 index 0000000000..4cbc47c72a --- /dev/null +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiPrivacyPolicyUrlExtension.cs @@ -0,0 +1,20 @@ +using Microsoft.OpenApi.Any; + +namespace Kiota.Builder.OpenApiExtensions; + +public class OpenApiPrivacyPolicyUrlExtension : OpenApiSimpleStringExtension +{ + public static string Name => "x-privacy-policy-url"; + public string? Privacy + { + get; set; + } + protected override string? ValueSelector => Privacy; + public static OpenApiPrivacyPolicyUrlExtension Parse(IOpenApiAny source) + { + return new OpenApiPrivacyPolicyUrlExtension + { + Privacy = ParseString(source) + }; + } +} diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiSimpleStringExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiSimpleStringExtension.cs new file mode 100644 index 0000000000..9978939205 --- /dev/null +++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiSimpleStringExtension.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Writers; + +namespace Kiota.Builder.OpenApiExtensions; + +public abstract class OpenApiSimpleStringExtension : IOpenApiExtension +{ + protected abstract string? ValueSelector + { + get; + } + public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(writer); + if (!string.IsNullOrWhiteSpace(ValueSelector)) + { + writer.WriteValue(ValueSelector); + } + } + public static string ParseString(IOpenApiAny source) + { + if (source is not OpenApiString rawString) throw new ArgumentOutOfRangeException(nameof(source)); + return rawString.Value; + } +} diff --git a/src/Kiota.Builder/PluginType.cs b/src/Kiota.Builder/PluginType.cs new file mode 100644 index 0000000000..b985a129b0 --- /dev/null +++ b/src/Kiota.Builder/PluginType.cs @@ -0,0 +1,8 @@ +namespace Kiota.Builder; + +public enum PluginType +{ + OpenAI, + APIManifest, + Microsoft +} diff --git a/src/Kiota.Builder/Plugins/AuthComparer.cs b/src/Kiota.Builder/Plugins/AuthComparer.cs new file mode 100644 index 0000000000..e5212d44de --- /dev/null +++ b/src/Kiota.Builder/Plugins/AuthComparer.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Plugins.Manifest; + +namespace Kiota.Builder.Plugins; + +internal class AuthComparer : IEqualityComparer +{ + /// + public bool Equals(Auth? x, Auth? y) + { + return x == null && y == null || x != null && y != null && GetHashCode(x) == GetHashCode(y); + } + /// + public int GetHashCode([DisallowNull] Auth obj) + { + if (obj == null) return 0; + return obj.Type is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Type.Value.ToString()) * 3; + } +} diff --git a/src/Kiota.Builder/Plugins/OpenAPiRuntimeComparer.cs b/src/Kiota.Builder/Plugins/OpenAPiRuntimeComparer.cs new file mode 100644 index 0000000000..fc699b070e --- /dev/null +++ b/src/Kiota.Builder/Plugins/OpenAPiRuntimeComparer.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Kiota.Builder.Lock; +using Microsoft.Plugins.Manifest; + +namespace Kiota.Builder.Plugins; + +internal class OpenAPIRuntimeComparer : IEqualityComparer +{ + public bool EvaluateFunctions + { + get; init; + } + private static readonly StringIEnumerableDeepComparer _stringIEnumerableDeepComparer = new(); + private static readonly AuthComparer _authComparer = new(); + private static readonly OpenApiRuntimeSpecComparer _openApiRuntimeSpecComparer = new(); + /// + public bool Equals(OpenApiRuntime? x, OpenApiRuntime? y) + { + return x == null && y == null || x != null && y != null && GetHashCode(x) == GetHashCode(y); + } + /// + public int GetHashCode([DisallowNull] OpenApiRuntime obj) + { + if (obj == null) return 0; + return (EvaluateFunctions ? _stringIEnumerableDeepComparer.GetHashCode(obj.RunForFunctions ?? Enumerable.Empty()) * 7 : 0) + + (obj.Spec is null ? 0 : _openApiRuntimeSpecComparer.GetHashCode(obj.Spec) * 5) + + (obj.Auth is null ? 0 : _authComparer.GetHashCode(obj.Auth) * 3); + } +} diff --git a/src/Kiota.Builder/Plugins/OpenApiRuntimeSpecComparer.cs b/src/Kiota.Builder/Plugins/OpenApiRuntimeSpecComparer.cs new file mode 100644 index 0000000000..38de08d0cf --- /dev/null +++ b/src/Kiota.Builder/Plugins/OpenApiRuntimeSpecComparer.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Plugins.Manifest; + +namespace Kiota.Builder.Plugins; + +public class OpenApiRuntimeSpecComparer : IEqualityComparer +{ + /// + public bool Equals(OpenApiRuntimeSpec? x, OpenApiRuntimeSpec? y) + { + return x == null && y == null || x != null && y != null && GetHashCode(x) == GetHashCode(y); + } + /// + public int GetHashCode([DisallowNull] OpenApiRuntimeSpec obj) + { + if (obj == null) return 0; + return (string.IsNullOrEmpty(obj.Url) ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Url) * 5) + + (string.IsNullOrEmpty(obj.ApiDescription) ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ApiDescription) * 3); + } +} diff --git a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs new file mode 100644 index 0000000000..09f073b182 --- /dev/null +++ b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Kiota.Builder.Configuration; +using Kiota.Builder.Extensions; +using Kiota.Builder.OpenApiExtensions; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.OpenApi.ApiManifest; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Services; +using Microsoft.OpenApi.Writers; +using Microsoft.Plugins.Manifest; + +namespace Kiota.Builder.Plugins; +public class PluginsGenerationService +{ + private readonly OpenApiDocument OAIDocument; + private readonly OpenApiUrlTreeNode TreeNode; + private readonly GenerationConfiguration Configuration; + + public PluginsGenerationService(OpenApiDocument document, OpenApiUrlTreeNode openApiUrlTreeNode, GenerationConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(openApiUrlTreeNode); + ArgumentNullException.ThrowIfNull(configuration); + OAIDocument = document; + TreeNode = openApiUrlTreeNode; + Configuration = configuration; + } + private static readonly OpenAPIRuntimeComparer _openAPIRuntimeComparer = new(); + private const string ManifestFileNameSuffix = ".json"; + private const string DescriptionRelativePath = "./openapi.yml"; + public async Task GenerateManifestAsync(CancellationToken cancellationToken = default) + { + // write the decription + var descriptionFullPath = Path.Combine(Configuration.OutputPath, DescriptionRelativePath); + var directory = Path.GetDirectoryName(descriptionFullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task + await using var descriptionStream = File.Create(descriptionFullPath, 4096); + await using var fileWriter = new StreamWriter(descriptionStream); +#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task + var descriptionWriter = new OpenApiYamlWriter(fileWriter); + OAIDocument.SerializeAsV3(descriptionWriter); + descriptionWriter.Flush(); + + // write the plugins + foreach (var pluginType in Configuration.PluginTypes) + { + var manifestOutputPath = Path.Combine(Configuration.OutputPath, $"{Configuration.ClientClassName.ToLowerInvariant()}-{pluginType.ToString().ToLowerInvariant()}{ManifestFileNameSuffix}"); +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task + await using var fileStream = File.Create(manifestOutputPath, 4096); + await using var writer = new Utf8JsonWriter(fileStream, new JsonWriterOptions { Indented = true }); +#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task + + switch (pluginType) + { + case PluginType.Microsoft: + var pluginDocument = GetManifestDocument(DescriptionRelativePath); + pluginDocument.Write(writer); + break; + case PluginType.APIManifest: + var apiManifest = new ApiManifestDocument("application"); //TODO add application name + apiManifest.ApiDependencies.AddOrReplace(Configuration.ClientClassName, Configuration.ToApiDependency(OAIDocument.HashCode ?? string.Empty, TreeNode?.GetRequestInfo().ToDictionary(static x => x.Key, static x => x.Value) ?? [])); + apiManifest.Write(writer); + break; + case PluginType.OpenAI://TODO add support for OpenAI plugin type generation + // intentional drop to the default case + default: + throw new NotImplementedException($"The {pluginType} plugin is not implemented."); + } + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + private PluginManifestDocument GetManifestDocument(string openApiDocumentPath) + { + var (runtimes, functions) = GetRuntimesAndFunctionsFromTree(TreeNode, openApiDocumentPath); + var descriptionForHuman = OAIDocument.Info?.Description.CleanupXMLString() is string d && !string.IsNullOrEmpty(d) ? d : $"Description for {OAIDocument.Info?.Title.CleanupXMLString()}"; + var descriptionForModel = descriptionForHuman; + string? legalUrl = null; + string? logoUrl = null; + string? privacyUrl = null; + if (OAIDocument.Info is not null) + { + if (OAIDocument.Info.Extensions.TryGetValue(OpenApiDescriptionForModelExtension.Name, out var descriptionExtension) && + descriptionExtension is OpenApiDescriptionForModelExtension extension && + !string.IsNullOrEmpty(extension.Description)) + descriptionForModel = extension.Description.CleanupXMLString(); + if (OAIDocument.Info.Extensions.TryGetValue(OpenApiLegalInfoUrlExtension.Name, out var legalExtension) && legalExtension is OpenApiLegalInfoUrlExtension legal) + legalUrl = legal.Legal; + if (OAIDocument.Info.Extensions.TryGetValue(OpenApiLogoExtension.Name, out var logoExtension) && logoExtension is OpenApiLogoExtension logo) + logoUrl = logo.Url; + if (OAIDocument.Info.Extensions.TryGetValue(OpenApiPrivacyPolicyUrlExtension.Name, out var privacyExtension) && privacyExtension is OpenApiPrivacyPolicyUrlExtension privacy) + privacyUrl = privacy.Privacy; + } + return new PluginManifestDocument + { + SchemaVersion = "v2", + NameForHuman = OAIDocument.Info?.Title.CleanupXMLString(), + // TODO name for model ??? + DescriptionForHuman = descriptionForHuman, + DescriptionForModel = descriptionForModel, + ContactEmail = OAIDocument.Info?.Contact?.Email, + Namespace = Configuration.ClientClassName, + LogoUrl = logoUrl, + LegalInfoUrl = legalUrl, + PrivacyPolicyUrl = privacyUrl, + Runtimes = [.. runtimes + .GroupBy(static x => x, _openAPIRuntimeComparer) + .Select(static x => + { + var result = x.First(); + result.RunForFunctions = x.SelectMany(static y => y.RunForFunctions).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + return result; + }) + .OrderBy(static x => x.RunForFunctions[0], StringComparer.OrdinalIgnoreCase)], + Functions = [.. functions.OrderBy(static x => x.Name, StringComparer.OrdinalIgnoreCase)] + }; + } + private (OpenApiRuntime[], Function[]) GetRuntimesAndFunctionsFromTree(OpenApiUrlTreeNode currentNode, string openApiDocumentPath) + { + var runtimes = new List(); + var functions = new List(); + if (currentNode.PathItems.TryGetValue(Constants.DefaultOpenApiLabel, out var pathItem)) + { + foreach (var operation in pathItem.Operations.Values.Where(static x => !string.IsNullOrEmpty(x.OperationId))) + { + runtimes.Add(new OpenApiRuntime + { + Auth = new AnonymousAuth(), + Spec = new OpenApiRuntimeSpec() + { + Url = openApiDocumentPath + }, + RunForFunctions = [operation.OperationId] + }); + var oasParameters = operation.Parameters + .Union(pathItem.Parameters.Where(static x => x.In is ParameterLocation.Path)) + .Where(static x => x.Schema?.Type is not null && scalarTypes.Contains(x.Schema.Type)) + .ToArray(); + //TODO add request body + + functions.Add(new Function + { + Name = operation.OperationId, + Description = + operation.Summary.CleanupXMLString() is string summary && !string.IsNullOrEmpty(summary) + ? summary + : operation.Description.CleanupXMLString(), + Parameters = oasParameters.Length == 0 + ? null + : new Parameters + { + Type = "object", + Properties = new Properties(oasParameters.ToDictionary( + static x => x.Name, + static x => new FunctionParameter() + { + Type = x.Schema.Type ?? string.Empty, + Description = x.Description.CleanupXMLString(), + Default = x.Schema.Default?.ToString() ?? string.Empty, + //TODO enums + })), + Required = oasParameters.Where(static x => x.Required).Select(static x => x.Name).ToList() + }, + States = GetStatesFromOperation(operation), + }); + } + } + foreach (var node in currentNode.Children) + { + var (childRuntimes, childFunctions) = GetRuntimesAndFunctionsFromTree(node.Value, openApiDocumentPath); + runtimes.AddRange(childRuntimes); + functions.AddRange(childFunctions); + } + return (runtimes.ToArray(), functions.ToArray()); + } + private static States? GetStatesFromOperation(OpenApiOperation openApiOperation) + { + return (GetStateFromExtension(openApiOperation, OpenApiAiReasoningInstructionsExtension.Name, static x => x.ReasoningInstructions), + GetStateFromExtension(openApiOperation, OpenApiAiRespondingInstructionsExtension.Name, static x => x.RespondingInstructions)) switch + { + (State reasoning, State responding) => new States + { + Reasoning = reasoning, + Responding = responding + }, + (State reasoning, _) => new States + { + Reasoning = reasoning + }, + (_, State responding) => new States + { + Responding = responding + }, + _ => null + }; + } + private static State? GetStateFromExtension(OpenApiOperation openApiOperation, string extensionName, Func> instructionsExtractor) + { + if (openApiOperation.Extensions.TryGetValue(extensionName, out var rExtRaw) && + rExtRaw is T rExt && + instructionsExtractor(rExt).Exists(static x => !string.IsNullOrEmpty(x))) + { + return new State + { + Instructions = new Instructions(instructionsExtractor(rExt).Where(static x => !string.IsNullOrEmpty(x)).Select(static x => x.CleanupXMLString()).ToList()) + }; + } + return null; + } + private static readonly HashSet scalarTypes = new(StringComparer.OrdinalIgnoreCase) { "string", "number", "integer", "boolean" }; + //TODO validate this is right, in OAS integer are under type number for the json schema, but integer is ok for query parameters +} diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs index 6ef6800a7c..ede1bb7db7 100644 --- a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs +++ b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs @@ -8,12 +8,8 @@ namespace Kiota.Builder.WorkspaceManagement; #pragma warning disable CA2227 // Collection properties should be read only -public class ApiClientConfiguration : ICloneable +public class ApiClientConfiguration : BaseApiConsumerConfiguration, ICloneable { - /// - /// The location of the OpenAPI description file. - /// - public string DescriptionLocation { get; set; } = string.Empty; /// /// The language for this client. /// @@ -24,18 +20,6 @@ public class ApiClientConfiguration : ICloneable #pragma warning disable CA1002 public List StructuredMimeTypes { get; set; } = new(); #pragma warning restore CA1002 - /// - /// The path patterns for API endpoints to include for this client. - /// - public HashSet IncludePatterns { get; set; } = new(); - /// - /// The path patterns for API endpoints to exclude for this client. - /// - public HashSet ExcludePatterns { get; set; } = new(); - /// - /// The output path for the generated code, related to the configuration file. - /// - public string OutputPath { get; set; } = string.Empty; /// /// The main namespace for this client. /// @@ -68,7 +52,7 @@ public bool ExcludeBackwardCompatible /// /// Initializes a new instance of the class. /// - public ApiClientConfiguration() + public ApiClientConfiguration() : base() { } @@ -76,7 +60,7 @@ public ApiClientConfiguration() /// Initializes a new instance of the class from an existing . /// /// The configuration to use to initialize the client configuration - public ApiClientConfiguration(GenerationConfiguration config) + public ApiClientConfiguration(GenerationConfiguration config) : base(config) { ArgumentNullException.ThrowIfNull(config); Language = config.Language.ToString(); @@ -85,11 +69,7 @@ public ApiClientConfiguration(GenerationConfiguration config) ExcludeBackwardCompatible = config.ExcludeBackwardCompatible; IncludeAdditionalData = config.IncludeAdditionalData; StructuredMimeTypes = config.StructuredMimeTypes.ToList(); - IncludePatterns = config.IncludePatterns; - ExcludePatterns = config.ExcludePatterns; - DescriptionLocation = config.OpenAPIFilePath; DisabledValidationRules = config.DisabledValidationRules; - OutputPath = config.OutputPath; } /// /// Updates the passed configuration with the values from the config file. @@ -108,43 +88,24 @@ public void UpdateGenerationConfigurationFromApiClientConfiguration(GenerationCo config.ExcludeBackwardCompatible = ExcludeBackwardCompatible; config.IncludeAdditionalData = IncludeAdditionalData; config.StructuredMimeTypes = new(StructuredMimeTypes); - config.IncludePatterns = IncludePatterns; - config.ExcludePatterns = ExcludePatterns; - config.OpenAPIFilePath = DescriptionLocation; config.DisabledValidationRules = DisabledValidationRules; - config.OutputPath = OutputPath; - config.ClientClassName = clientName; - config.Serializers.Clear(); - config.Deserializers.Clear(); - if (requests is { Count: > 0 }) - { - config.PatternsOverride = requests.Where(static x => !x.Exclude && !string.IsNullOrEmpty(x.Method) && !string.IsNullOrEmpty(x.UriTemplate)) - .Select(static x => $"/{x.UriTemplate}#{x.Method!.ToUpperInvariant()}") - .ToHashSet(); - } + UpdateGenerationConfigurationFromBase(config, clientName, requests); } public object Clone() { - return new ApiClientConfiguration + var result = new ApiClientConfiguration { - DescriptionLocation = DescriptionLocation, Language = Language, StructuredMimeTypes = [.. StructuredMimeTypes], - IncludePatterns = new(IncludePatterns, StringComparer.OrdinalIgnoreCase), - ExcludePatterns = new(ExcludePatterns, StringComparer.OrdinalIgnoreCase), - OutputPath = OutputPath, ClientNamespaceName = ClientNamespaceName, UsesBackingStore = UsesBackingStore, IncludeAdditionalData = IncludeAdditionalData, ExcludeBackwardCompatible = ExcludeBackwardCompatible, DisabledValidationRules = new(DisabledValidationRules, StringComparer.OrdinalIgnoreCase), }; - } - public void NormalizePaths(string targetDirectory) - { - if (Path.IsPathRooted(OutputPath)) - OutputPath = "./" + Path.GetRelativePath(targetDirectory, OutputPath); + CloneBase(result); + return result; } } #pragma warning restore CA2227 // Collection properties should be read only diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfigurationComparer.cs b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfigurationComparer.cs index c1503a4ded..e70537639d 100644 --- a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfigurationComparer.cs +++ b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfigurationComparer.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Kiota.Builder.Lock; @@ -8,29 +7,21 @@ namespace Kiota.Builder.WorkspaceManagement; /// /// Compares two instances. /// -public class ApiClientConfigurationComparer : IEqualityComparer +public class ApiClientConfigurationComparer : BaseApiConsumerConfigurationComparer { private static readonly StringIEnumerableDeepComparer _stringIEnumerableDeepComparer = new(); /// - public bool Equals(ApiClientConfiguration? x, ApiClientConfiguration? y) - { - return x == null && y == null || x != null && y != null && GetHashCode(x) == GetHashCode(y); - } - /// - public int GetHashCode([DisallowNull] ApiClientConfiguration obj) + public override int GetHashCode([DisallowNull] ApiClientConfiguration obj) { if (obj == null) return 0; return _stringIEnumerableDeepComparer.GetHashCode(obj.DisabledValidationRules?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 37 + - (string.IsNullOrEmpty(obj.DescriptionLocation) ? 0 : obj.DescriptionLocation.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 31 + - (string.IsNullOrEmpty(obj.OutputPath) ? 0 : obj.OutputPath.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 19 + - (string.IsNullOrEmpty(obj.ClientNamespaceName) ? 0 : obj.ClientNamespaceName.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 23 + - (string.IsNullOrEmpty(obj.Language) ? 0 : obj.Language.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 19 + - obj.ExcludeBackwardCompatible.GetHashCode() * 17 + - obj.UsesBackingStore.GetHashCode() * 13 + - obj.IncludeAdditionalData.GetHashCode() * 7 + - _stringIEnumerableDeepComparer.GetHashCode(obj.StructuredMimeTypes?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 5 + - _stringIEnumerableDeepComparer.GetHashCode(obj.IncludePatterns?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 3 + - _stringIEnumerableDeepComparer.GetHashCode(obj.ExcludePatterns?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 2; + (string.IsNullOrEmpty(obj.ClientNamespaceName) ? 0 : obj.ClientNamespaceName.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 31 + + (string.IsNullOrEmpty(obj.Language) ? 0 : obj.Language.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 23 + + obj.ExcludeBackwardCompatible.GetHashCode() * 19 + + obj.UsesBackingStore.GetHashCode() * 17 + + obj.IncludeAdditionalData.GetHashCode() * 13 + + _stringIEnumerableDeepComparer.GetHashCode(obj.StructuredMimeTypes?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 11 + + base.GetHashCode(obj); } } diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfiguration.cs b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfiguration.cs new file mode 100644 index 0000000000..5e690e8f64 --- /dev/null +++ b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfiguration.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Kiota.Builder.Configuration; +using Microsoft.OpenApi.ApiManifest; + +namespace Kiota.Builder.WorkspaceManagement; + +#pragma warning disable CA2227 // Collection properties should be read only +public class ApiPluginConfiguration : BaseApiConsumerConfiguration, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + public ApiPluginConfiguration() : base() + { + + } + /// + /// Initializes a new instance of the class from an existing . + /// + /// The configuration to use to initialize the client configuration + public ApiPluginConfiguration(GenerationConfiguration config) : base(config) + { + ArgumentNullException.ThrowIfNull(config); + Types = config.PluginTypes.Select(x => x.ToString()).ToHashSet(); + } + public HashSet Types { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public object Clone() + { + var result = new ApiPluginConfiguration() + { + Types = new HashSet(Types, StringComparer.OrdinalIgnoreCase) + }; + CloneBase(result); + return result; + } + /// + /// Updates the passed configuration with the values from the config file. + /// + /// Generation configuration to update. + /// Plugin name. + /// The requests to use when updating an existing client. + public void UpdateGenerationConfigurationFromApiPluginConfiguration(GenerationConfiguration config, string pluginName, IList? requests = default) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrEmpty(pluginName); + config.PluginTypes = Types.Select(x => Enum.TryParse(x, out var result) ? result : (PluginType?)null).OfType().ToHashSet(); + UpdateGenerationConfigurationFromBase(config, pluginName, requests); + } +} +#pragma warning restore CA2227 // Collection properties should be read only diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfigurationComparer.cs b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfigurationComparer.cs new file mode 100644 index 0000000000..44f1366325 --- /dev/null +++ b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfigurationComparer.cs @@ -0,0 +1,21 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Kiota.Builder.Lock; + +namespace Kiota.Builder.WorkspaceManagement; +/// +/// Compares two instances. +/// +public class ApiPluginConfigurationComparer : BaseApiConsumerConfigurationComparer +{ + private static readonly StringIEnumerableDeepComparer _stringIEnumerableDeepComparer = new(); + /// + public override int GetHashCode([DisallowNull] ApiPluginConfiguration obj) + { + if (obj == null) return 0; + return + _stringIEnumerableDeepComparer.GetHashCode(obj.Types?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 11 + + base.GetHashCode(obj); + } +} diff --git a/src/Kiota.Builder/WorkspaceManagement/BaseApiConsumerConfiguration.cs b/src/Kiota.Builder/WorkspaceManagement/BaseApiConsumerConfiguration.cs new file mode 100644 index 0000000000..e2a9487f5e --- /dev/null +++ b/src/Kiota.Builder/WorkspaceManagement/BaseApiConsumerConfiguration.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Kiota.Builder.Configuration; +using Microsoft.OpenApi.ApiManifest; + +namespace Kiota.Builder.WorkspaceManagement; + +#pragma warning disable CA2227 // Collection properties should be read only +public abstract class BaseApiConsumerConfiguration +{ + private protected BaseApiConsumerConfiguration() + { + + } + private protected BaseApiConsumerConfiguration(GenerationConfiguration config) + { + ArgumentNullException.ThrowIfNull(config); + DescriptionLocation = config.OpenAPIFilePath; + IncludePatterns = new HashSet(config.IncludePatterns); + ExcludePatterns = new HashSet(config.ExcludePatterns); + OutputPath = config.OutputPath; + } + /// + /// The location of the OpenAPI description file. + /// + public string DescriptionLocation { get; set; } = string.Empty; + /// + /// The path patterns for API endpoints to include for this client. + /// + public HashSet IncludePatterns { get; set; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// The path patterns for API endpoints to exclude for this client. + /// + public HashSet ExcludePatterns { get; set; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// The output path for the generated code, related to the configuration file. + /// + public string OutputPath { get; set; } = string.Empty; + public void NormalizePaths(string targetDirectory) + { + if (Path.IsPathRooted(OutputPath)) + OutputPath = "./" + Path.GetRelativePath(targetDirectory, OutputPath); + } + protected void CloneBase(BaseApiConsumerConfiguration target) + { + ArgumentNullException.ThrowIfNull(target); + target.OutputPath = OutputPath; + target.DescriptionLocation = DescriptionLocation; + target.IncludePatterns = new HashSet(IncludePatterns, StringComparer.OrdinalIgnoreCase); + target.ExcludePatterns = new HashSet(ExcludePatterns, StringComparer.OrdinalIgnoreCase); + } + protected void UpdateGenerationConfigurationFromBase(GenerationConfiguration config, string clientName, IList? requests) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrEmpty(clientName); + config.IncludePatterns = IncludePatterns; + config.ExcludePatterns = ExcludePatterns; + config.OpenAPIFilePath = DescriptionLocation; + config.OutputPath = OutputPath; + config.ClientClassName = clientName; + config.Serializers.Clear(); + config.Deserializers.Clear(); + if (requests is { Count: > 0 }) + { + config.PatternsOverride = requests.Where(static x => !x.Exclude && !string.IsNullOrEmpty(x.Method) && !string.IsNullOrEmpty(x.UriTemplate)) + .Select(static x => $"/{x.UriTemplate}#{x.Method!.ToUpperInvariant()}") + .ToHashSet(); + } + } +} +#pragma warning restore CA2227 // Collection properties should be read only diff --git a/src/Kiota.Builder/WorkspaceManagement/BaseApiConsumerConfigurationComparer.cs b/src/Kiota.Builder/WorkspaceManagement/BaseApiConsumerConfigurationComparer.cs new file mode 100644 index 0000000000..8a35a9a99a --- /dev/null +++ b/src/Kiota.Builder/WorkspaceManagement/BaseApiConsumerConfigurationComparer.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Kiota.Builder.Lock; + +namespace Kiota.Builder.WorkspaceManagement; +public abstract class BaseApiConsumerConfigurationComparer : IEqualityComparer where T : BaseApiConsumerConfiguration +{ + private static readonly StringIEnumerableDeepComparer _stringIEnumerableDeepComparer = new(); + /// + public virtual bool Equals(T? x, T? y) + { + return x == null && y == null || x != null && y != null && GetHashCode(x) == GetHashCode(y); + } + + public virtual int GetHashCode([DisallowNull] T obj) + { + if (obj == null) return 0; + return (string.IsNullOrEmpty(obj.DescriptionLocation) ? 0 : obj.DescriptionLocation.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 7 + + (string.IsNullOrEmpty(obj.OutputPath) ? 0 : obj.OutputPath.GetHashCode(StringComparison.OrdinalIgnoreCase)) * 5 + + _stringIEnumerableDeepComparer.GetHashCode(obj.IncludePatterns?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 3 + + _stringIEnumerableDeepComparer.GetHashCode(obj.ExcludePatterns?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 2; + } +} diff --git a/src/Kiota.Builder/WorkspaceManagement/DescriptionStorageService.cs b/src/Kiota.Builder/WorkspaceManagement/DescriptionStorageService.cs index 170f1f2bd0..b9cb1bcf97 100644 --- a/src/Kiota.Builder/WorkspaceManagement/DescriptionStorageService.cs +++ b/src/Kiota.Builder/WorkspaceManagement/DescriptionStorageService.cs @@ -9,7 +9,7 @@ namespace Kiota.Builder.WorkspaceManagement; public class DescriptionStorageService { public const string KiotaDirectorySegment = ".kiota"; - internal const string DescriptionsSubDirectoryRelativePath = $"{KiotaDirectorySegment}/clients"; + internal const string DescriptionsSubDirectoryRelativePath = $"{KiotaDirectorySegment}/documents"; private readonly string TargetDirectory; public DescriptionStorageService(string targetDirectory) { diff --git a/src/Kiota.Builder/WorkspaceManagement/WorkspaceConfiguration.cs b/src/Kiota.Builder/WorkspaceManagement/WorkspaceConfiguration.cs index 29cbb638f3..00bd947474 100644 --- a/src/Kiota.Builder/WorkspaceManagement/WorkspaceConfiguration.cs +++ b/src/Kiota.Builder/WorkspaceManagement/WorkspaceConfiguration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; namespace Kiota.Builder.WorkspaceManagement; @@ -15,13 +16,25 @@ public class WorkspaceConfiguration : ICloneable /// The clients to generate. /// public Dictionary Clients { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary Plugins { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); #pragma warning restore CA2227 // Collection properties should be read only + [JsonIgnore] + public bool AreConsumersKeysUnique + { + get + { + return Clients.Keys.Concat(Plugins.Keys).GroupBy(static x => x, StringComparer.OrdinalIgnoreCase).All(static x => x.Count() == 1); + } + } + [JsonIgnore] + public bool AnyConsumerPresent => Clients.Count != 0 || Plugins.Count != 0; public object Clone() { return new WorkspaceConfiguration { Version = Version, - Clients = Clients.ToDictionary(static x => x.Key, static x => (ApiClientConfiguration)x.Value.Clone(), StringComparer.OrdinalIgnoreCase) + Clients = Clients.ToDictionary(static x => x.Key, static x => (ApiClientConfiguration)x.Value.Clone(), StringComparer.OrdinalIgnoreCase), + Plugins = Plugins.ToDictionary(static x => x.Key, static x => (ApiPluginConfiguration)x.Value.Clone(), StringComparer.OrdinalIgnoreCase) }; } } diff --git a/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs b/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs index a010b2ec2f..6a78b05806 100644 --- a/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs +++ b/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs @@ -9,7 +9,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using AsyncKeyedLock; using Kiota.Builder.Configuration; using Kiota.Builder.Extensions; using Kiota.Builder.Lock; @@ -41,11 +40,28 @@ public WorkspaceManagementService(ILogger logger, HttpClient httpClient, bool us private readonly LockManagementService lockManagementService = new(); private readonly WorkspaceConfigurationStorageService workspaceConfigurationStorageService; private readonly DescriptionStorageService descriptionStorageService; - public async Task IsClientPresent(string clientName, CancellationToken cancellationToken = default) + public async Task IsConsumerPresent(string clientName, CancellationToken cancellationToken = default) { if (!UseKiotaConfig) return false; var (wsConfig, _) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false); - return wsConfig?.Clients.ContainsKey(clientName) ?? false; + return wsConfig is not null && (wsConfig.Clients.ContainsKey(clientName) || wsConfig.Plugins.ContainsKey(clientName)); + } + private BaseApiConsumerConfiguration UpdateConsumerConfiguration(GenerationConfiguration generationConfiguration, WorkspaceConfiguration wsConfig) + { + if (generationConfiguration.IsPluginConfiguration) + { + var generationPluginConfig = new ApiPluginConfiguration(generationConfiguration); + generationPluginConfig.NormalizePaths(WorkingDirectory); + wsConfig.Plugins.AddOrReplace(generationConfiguration.ClientClassName, generationPluginConfig); + return generationPluginConfig; + } + else + { + var generationClientConfig = new ApiClientConfiguration(generationConfiguration); + generationClientConfig.NormalizePaths(WorkingDirectory); + wsConfig.Clients.AddOrReplace(generationConfiguration.ClientClassName, generationClientConfig); + return generationClientConfig; + } } public async Task UpdateStateFromConfigurationAsync(GenerationConfiguration generationConfiguration, string descriptionHash, Dictionary> templatesWithOperations, Stream descriptionStream, CancellationToken cancellationToken = default) { @@ -53,10 +69,8 @@ public async Task UpdateStateFromConfigurationAsync(GenerationConfiguration gene if (UseKiotaConfig) { var (wsConfig, manifest) = await LoadConfigurationAndManifestAsync(cancellationToken).ConfigureAwait(false); - var generationClientConfig = new ApiClientConfiguration(generationConfiguration); - generationClientConfig.NormalizePaths(WorkingDirectory); - wsConfig.Clients.AddOrReplace(generationConfiguration.ClientClassName, generationClientConfig); - var inputConfigurationHash = await GetConfigurationHashAsync(generationClientConfig, descriptionHash).ConfigureAwait(false); + var generationClientConfig = UpdateConsumerConfiguration(generationConfiguration, wsConfig); + var inputConfigurationHash = await GetConsumerConfigurationHashAsync(generationClientConfig, descriptionHash).ConfigureAwait(false); manifest.ApiDependencies.AddOrReplace(generationConfiguration.ClientClassName, generationConfiguration.ToApiDependency(inputConfigurationHash, templatesWithOperations)); await workspaceConfigurationStorageService.UpdateWorkspaceConfigurationAsync(wsConfig, manifest, cancellationToken).ConfigureAwait(false); if (descriptionStream != Stream.Null) @@ -87,6 +101,7 @@ public async Task BackupStateAsync(string outputPath, CancellationToken cancella } private static readonly KiotaLockComparer lockComparer = new(); private static readonly ApiClientConfigurationComparer clientConfigurationComparer = new(); + private static readonly ApiPluginConfigurationComparer pluginConfigurationComparer = new(); private static readonly ApiDependencyComparer apiDependencyComparer = new(); public async Task ShouldGenerateAsync(GenerationConfiguration inputConfig, string descriptionHash, CancellationToken cancellationToken = default) { @@ -95,15 +110,26 @@ public async Task ShouldGenerateAsync(GenerationConfiguration inputConfig, if (UseKiotaConfig) { var (wsConfig, apiManifest) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false); - if ((wsConfig?.Clients.TryGetValue(inputConfig.ClientClassName, out var existingClientConfig) ?? false) && - (apiManifest?.ApiDependencies.TryGetValue(inputConfig.ClientClassName, out var existingApiManifest) ?? false)) + if (wsConfig is null || apiManifest is null) + return true; + if (wsConfig.Clients.TryGetValue(inputConfig.ClientClassName, out var existingClientConfig) && + apiManifest.ApiDependencies.TryGetValue(inputConfig.ClientClassName, out var existingApiManifest)) { var inputClientConfig = new ApiClientConfiguration(inputConfig); inputClientConfig.NormalizePaths(WorkingDirectory); - var inputConfigurationHash = await GetConfigurationHashAsync(inputClientConfig, descriptionHash).ConfigureAwait(false); + var inputConfigurationHash = await GetConsumerConfigurationHashAsync(inputClientConfig, descriptionHash).ConfigureAwait(false); return !clientConfigurationComparer.Equals(existingClientConfig, inputClientConfig) || !apiDependencyComparer.Equals(inputConfig.ToApiDependency(inputConfigurationHash, []), existingApiManifest); } + if (wsConfig.Plugins.TryGetValue(inputConfig.ClientClassName, out var existingPluginConfig) && + apiManifest.ApiDependencies.TryGetValue(inputConfig.ClientClassName, out var existingPluginApiManifest)) + { + var inputClientConfig = new ApiPluginConfiguration(inputConfig); + inputClientConfig.NormalizePaths(WorkingDirectory); + var inputConfigurationHash = await GetConsumerConfigurationHashAsync(inputClientConfig, descriptionHash).ConfigureAwait(false); + return !pluginConfigurationComparer.Equals(existingPluginConfig, inputClientConfig) || + !apiDependencyComparer.Equals(inputConfig.ToApiDependency(inputConfigurationHash, []), existingPluginApiManifest); + } return true; } else @@ -127,23 +153,43 @@ public async Task ShouldGenerateAsync(GenerationConfiguration inputConfig, return null; return await descriptionStorageService.GetDescriptionAsync(clientName, new Uri(inputPath).GetFileExtension(), cancellationToken).ConfigureAwait(false); } - public async Task RemoveClientAsync(string clientName, bool cleanOutput = false, CancellationToken cancellationToken = default) + public Task RemoveClientAsync(string clientName, bool cleanOutput = false, CancellationToken cancellationToken = default) + { + return RemoveConsumerInternalAsync(clientName, + static wsConfig => wsConfig.Clients, + cleanOutput, + "client", + cancellationToken + ); + } + public Task RemovePluginAsync(string clientName, bool cleanOutput = false, CancellationToken cancellationToken = default) + { + return RemoveConsumerInternalAsync(clientName, + static wsConfig => wsConfig.Plugins, + cleanOutput, + "plugin", + cancellationToken + ); + } + private async Task RemoveConsumerInternalAsync(string consumerName, Func> consumerRetrieval, bool cleanOutput, string consumerDisplayName, CancellationToken cancellationToken) where T : BaseApiConsumerConfiguration { if (!UseKiotaConfig) - throw new InvalidOperationException("Cannot remove a client in lock mode"); + throw new InvalidOperationException($"Cannot remove a {consumerDisplayName} in lock mode"); var (wsConfig, manifest) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false); if (wsConfig is null) - throw new InvalidOperationException("Cannot remove a client without a configuration"); + throw new InvalidOperationException($"Cannot remove a {consumerDisplayName} without a configuration"); + + var consumers = consumerRetrieval(wsConfig); + if (cleanOutput && consumers.TryGetValue(consumerName, out var consumerConfig) && Directory.Exists(consumerConfig.OutputPath)) + Directory.Delete(consumerConfig.OutputPath, true); - if (cleanOutput && wsConfig.Clients.TryGetValue(clientName, out var clientConfig) && Directory.Exists(clientConfig.OutputPath)) - Directory.Delete(clientConfig.OutputPath, true); + if (!consumers.Remove(consumerName)) + throw new InvalidOperationException($"The {consumerDisplayName} {consumerName} was not found in the configuration"); - if (!wsConfig.Clients.Remove(clientName)) - throw new InvalidOperationException($"The client {clientName} was not found in the configuration"); - manifest?.ApiDependencies.Remove(clientName); + manifest?.ApiDependencies.Remove(consumerName); await workspaceConfigurationStorageService.UpdateWorkspaceConfigurationAsync(wsConfig, manifest, cancellationToken).ConfigureAwait(false); - descriptionStorageService.RemoveDescription(clientName); - if (wsConfig.Clients.Count == 0) + descriptionStorageService.RemoveDescription(consumerName); + if (!wsConfig.AnyConsumerPresent) descriptionStorageService.Clean(); } private static readonly JsonSerializerOptions options = new() @@ -154,11 +200,13 @@ public async Task RemoveClientAsync(string clientName, bool cleanOutput = false, private static readonly WorkspaceConfigurationGenerationContext context = new(options); private static readonly ThreadLocal HashAlgorithm = new(SHA256.Create); private readonly string WorkingDirectory; - - private async Task GetConfigurationHashAsync(ApiClientConfiguration apiClientConfiguration, string descriptionHash) + private async Task GetConsumerConfigurationHashAsync(T apiClientConfiguration, string descriptionHash) where T : BaseApiConsumerConfiguration { using var stream = new MemoryStream(); - await JsonSerializer.SerializeAsync(stream, apiClientConfiguration, context.ApiClientConfiguration).ConfigureAwait(false); + if (apiClientConfiguration is ApiClientConfiguration) + await JsonSerializer.SerializeAsync(stream, apiClientConfiguration, context.ApiClientConfiguration).ConfigureAwait(false); + else + await JsonSerializer.SerializeAsync(stream, apiClientConfiguration, context.ApiPluginConfiguration).ConfigureAwait(false); await stream.WriteAsync(Encoding.UTF8.GetBytes(descriptionHash)).ConfigureAwait(false); stream.Position = 0; if (HashAlgorithm.Value is null) @@ -246,7 +294,7 @@ public async Task> MigrateFromLockFileAsync(string clientNam var clientConfiguration = new ApiClientConfiguration(generationConfiguration); clientConfiguration.NormalizePaths(WorkingDirectory); wsConfig.Clients.Add(generationConfiguration.ClientClassName, clientConfiguration); - var inputConfigurationHash = await GetConfigurationHashAsync(clientConfiguration, "migrated-pending-generate").ConfigureAwait(false); + var inputConfigurationHash = await GetConsumerConfigurationHashAsync(clientConfiguration, "migrated-pending-generate").ConfigureAwait(false); // because it's a migration, we don't want to calculate the exact hash since the description might have changed since the initial generation that created the lock file apiManifest.ApiDependencies.Add(generationConfiguration.ClientClassName, generationConfiguration.ToApiDependency(inputConfigurationHash, [])); lockManagementService.DeleteLockFile(Path.Combine(WorkingDirectory, clientConfiguration.OutputPath)); diff --git a/src/kiota/Handlers/Client/AddHandler.cs b/src/kiota/Handlers/Client/AddHandler.cs index 9808f3835a..e4d2237f89 100644 --- a/src/kiota/Handlers/Client/AddHandler.cs +++ b/src/kiota/Handlers/Client/AddHandler.cs @@ -91,7 +91,7 @@ public override async Task InvokeAsync(InvocationContext context) Configuration.Generation.Language = language; WarnUsingPreviewLanguage(language); Configuration.Generation.SkipGeneration = skipGeneration; - Configuration.Generation.Operation = ClientOperation.Add; + Configuration.Generation.Operation = ConsumerOperation.Add; if (includePatterns.Count != 0) Configuration.Generation.IncludePatterns = includePatterns.Select(static x => x.TrimQuotes()).ToHashSet(StringComparer.OrdinalIgnoreCase); if (excludePatterns.Count != 0) diff --git a/src/kiota/Handlers/Client/EditHandler.cs b/src/kiota/Handlers/Client/EditHandler.cs index 09bd603f2e..6b65b92759 100644 --- a/src/kiota/Handlers/Client/EditHandler.cs +++ b/src/kiota/Handlers/Client/EditHandler.cs @@ -83,7 +83,7 @@ public override async Task InvokeAsync(InvocationContext context) CancellationToken cancellationToken = context.BindingContext.GetService(typeof(CancellationToken)) is CancellationToken token ? token : CancellationToken.None; Configuration.Generation.SkipGeneration = skipGeneration; - Configuration.Generation.Operation = ClientOperation.Edit; + Configuration.Generation.Operation = ConsumerOperation.Edit; var (loggerFactory, logger) = GetLoggerAndFactory(context, Configuration.Generation.OutputPath); using (loggerFactory) diff --git a/src/kiota/Handlers/Client/GenerateHandler.cs b/src/kiota/Handlers/Client/GenerateHandler.cs index d4787f744c..27e90907a3 100644 --- a/src/kiota/Handlers/Client/GenerateHandler.cs +++ b/src/kiota/Handlers/Client/GenerateHandler.cs @@ -56,7 +56,7 @@ public override async Task InvokeAsync(InvocationContext context) DefaultSerializersAndDeserializers(generationConfiguration); generationConfiguration.ClearCache = refresh; generationConfiguration.CleanOutput = refresh; - generationConfiguration.Operation = ClientOperation.Generate; + generationConfiguration.Operation = ConsumerOperation.Generate; var builder = new KiotaBuilder(logger, generationConfiguration, httpClient, true); var result = await builder.GenerateClientAsync(cancellationToken).ConfigureAwait(false); if (result) @@ -84,6 +84,5 @@ public override async Task InvokeAsync(InvocationContext context) #endif } } - throw new System.NotImplementedException(); } } diff --git a/src/kiota/Handlers/Plugin/AddHandler.cs b/src/kiota/Handlers/Plugin/AddHandler.cs new file mode 100644 index 0000000000..be662926cd --- /dev/null +++ b/src/kiota/Handlers/Plugin/AddHandler.cs @@ -0,0 +1,98 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.Json; +using Kiota.Builder; +using Kiota.Builder.Configuration; +using Kiota.Builder.Extensions; +using Kiota.Builder.WorkspaceManagement; +using Microsoft.Extensions.Logging; + +namespace kiota.Handlers.Plugin; + +internal class AddHandler : BaseKiotaCommandHandler +{ + public required Option ClassOption + { + get; init; + } + public required Option OutputOption + { + get; init; + } + public required Option> PluginTypesOption + { + get; init; + } + public required Option DescriptionOption + { + get; init; + } + public required Option> IncludePatternsOption + { + get; init; + } + public required Option> ExcludePatternsOption + { + get; init; + } + public required Option SkipGenerationOption + { + get; init; + } + public override async Task InvokeAsync(InvocationContext context) + { + string output = context.ParseResult.GetValueForOption(OutputOption) ?? string.Empty; + List pluginTypes = context.ParseResult.GetValueForOption(PluginTypesOption) ?? []; + string openapi = context.ParseResult.GetValueForOption(DescriptionOption) ?? string.Empty; + bool skipGeneration = context.ParseResult.GetValueForOption(SkipGenerationOption); + string className = context.ParseResult.GetValueForOption(ClassOption) ?? string.Empty; + List includePatterns = context.ParseResult.GetValueForOption(IncludePatternsOption) ?? []; + List excludePatterns = context.ParseResult.GetValueForOption(ExcludePatternsOption) ?? []; + CancellationToken cancellationToken = context.BindingContext.GetService(typeof(CancellationToken)) is CancellationToken token ? token : CancellationToken.None; + AssignIfNotNullOrEmpty(output, (c, s) => c.OutputPath = s); + AssignIfNotNullOrEmpty(openapi, (c, s) => c.OpenAPIFilePath = s); + AssignIfNotNullOrEmpty(className, (c, s) => c.ClientClassName = s); + Configuration.Generation.SkipGeneration = skipGeneration; + Configuration.Generation.Operation = ConsumerOperation.Add; + if (pluginTypes.Count != 0) + Configuration.Generation.PluginTypes = pluginTypes.ToHashSet(); + if (includePatterns.Count != 0) + Configuration.Generation.IncludePatterns = includePatterns.Select(static x => x.TrimQuotes()).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (excludePatterns.Count != 0) + Configuration.Generation.ExcludePatterns = excludePatterns.Select(static x => x.TrimQuotes()).ToHashSet(StringComparer.OrdinalIgnoreCase); + Configuration.Generation.OpenAPIFilePath = GetAbsolutePath(Configuration.Generation.OpenAPIFilePath); + Configuration.Generation.OutputPath = NormalizeSlashesInPath(GetAbsolutePath(Configuration.Generation.OutputPath)); + var (loggerFactory, logger) = GetLoggerAndFactory(context, Configuration.Generation.OutputPath); + using (loggerFactory) + { + await CheckForNewVersionAsync(logger, cancellationToken).ConfigureAwait(false); + logger.AppendInternalTracing(); + logger.LogTrace("configuration: {configuration}", JsonSerializer.Serialize(Configuration, KiotaConfigurationJsonContext.Default.KiotaConfiguration)); + try + { + var builder = new KiotaBuilder(logger, Configuration.Generation, httpClient, true); + var result = await builder.GeneratePluginAsync(cancellationToken).ConfigureAwait(false); + if (result) + DisplaySuccess("Generation completed successfully"); + else if (skipGeneration) + { + DisplaySuccess("Generation skipped as --skip-generation was passed"); + DisplayGenerateCommandHint(); + } // else we get an error because we're adding a client that already exists + var manifestPath = $"{GetAbsolutePath(Path.Combine(WorkspaceConfigurationStorageService.KiotaDirectorySegment, WorkspaceConfigurationStorageService.ManifestFileName))}#{Configuration.Generation.ClientClassName}"; + DisplayGenerateAdvancedHint(includePatterns, excludePatterns, string.Empty, manifestPath, "plugin add"); + return 0; + } + catch (Exception ex) + { +#if DEBUG + logger.LogCritical(ex, "error adding the client: {exceptionMessage}", ex.Message); + throw; // so debug tools go straight to the source of the exception when attached +#else + logger.LogCritical("error adding the client: {exceptionMessage}", ex.Message); + return 1; +#endif + } + } + } +} diff --git a/src/kiota/Handlers/Plugin/EditHandler.cs b/src/kiota/Handlers/Plugin/EditHandler.cs new file mode 100644 index 0000000000..ef78b19c29 --- /dev/null +++ b/src/kiota/Handlers/Plugin/EditHandler.cs @@ -0,0 +1,121 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.Json; +using Kiota.Builder; +using Kiota.Builder.Configuration; +using Kiota.Builder.Extensions; +using Kiota.Builder.WorkspaceManagement; +using Microsoft.Extensions.Logging; + +namespace kiota.Handlers.Plugin; + +internal class EditHandler : BaseKiotaCommandHandler +{ + public required Option ClassOption + { + get; init; + } + public required Option OutputOption + { + get; init; + } + public required Option DescriptionOption + { + get; init; + } + public required Option> IncludePatternsOption + { + get; init; + } + public required Option> ExcludePatternsOption + { + get; init; + } + public required Option SkipGenerationOption + { + get; init; + } + public required Option> PluginTypesOption + { + get; init; + } + + public override async Task InvokeAsync(InvocationContext context) + { + string output = context.ParseResult.GetValueForOption(OutputOption) ?? string.Empty; + List? pluginTypes = context.ParseResult.GetValueForOption(PluginTypesOption); + string openapi = context.ParseResult.GetValueForOption(DescriptionOption) ?? string.Empty; + bool skipGeneration = context.ParseResult.GetValueForOption(SkipGenerationOption); + string className = context.ParseResult.GetValueForOption(ClassOption) ?? string.Empty; + List? includePatterns = context.ParseResult.GetValueForOption(IncludePatternsOption); + List? excludePatterns = context.ParseResult.GetValueForOption(ExcludePatternsOption); + CancellationToken cancellationToken = context.BindingContext.GetService(typeof(CancellationToken)) is CancellationToken token ? token : CancellationToken.None; + + Configuration.Generation.SkipGeneration = skipGeneration; + Configuration.Generation.Operation = ConsumerOperation.Edit; + + var (loggerFactory, logger) = GetLoggerAndFactory(context, Configuration.Generation.OutputPath); + using (loggerFactory) + { + await CheckForNewVersionAsync(logger, cancellationToken).ConfigureAwait(false); + logger.AppendInternalTracing(); + logger.LogTrace("configuration: {configuration}", JsonSerializer.Serialize(Configuration, KiotaConfigurationJsonContext.Default.KiotaConfiguration)); + + try + { + var workspaceStorageService = new WorkspaceConfigurationStorageService(Directory.GetCurrentDirectory()); + var (config, _) = await workspaceStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false); + if (config == null) + { + DisplayError("The workspace configuration is missing, please run the init command first."); + return 1; + } + if (!config.Plugins.TryGetValue(className, out var pluginConfiguration)) + { + DisplayError($"No plugin found with the provided name {className}"); + return 1; + } + pluginConfiguration.UpdateGenerationConfigurationFromApiPluginConfiguration(Configuration.Generation, className); + AssignIfNotNullOrEmpty(output, (c, s) => c.OutputPath = s); + AssignIfNotNullOrEmpty(openapi, (c, s) => c.OpenAPIFilePath = s); + AssignIfNotNullOrEmpty(className, (c, s) => c.ClientClassName = s); + if (includePatterns is { Count: > 0 }) + Configuration.Generation.IncludePatterns = includePatterns.Select(static x => x.TrimQuotes()).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (excludePatterns is { Count: > 0 }) + Configuration.Generation.ExcludePatterns = excludePatterns.Select(static x => x.TrimQuotes()).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (pluginTypes is { Count: > 0 }) + Configuration.Generation.PluginTypes = pluginTypes.ToHashSet(); + + DefaultSerializersAndDeserializers(Configuration.Generation); + var builder = new KiotaBuilder(logger, Configuration.Generation, httpClient, true); + var result = await builder.GeneratePluginAsync(cancellationToken).ConfigureAwait(false); + if (result) + DisplaySuccess("Generation completed successfully"); + else if (skipGeneration) + { + DisplaySuccess("Generation skipped as --skip-generation was passed"); + DisplayGenerateCommandHint(); + } + else + { + DisplayWarning("Generation skipped as no changes were detected"); + DisplayCleanHint("plugin generate", "--refresh"); + } + var manifestPath = $"{GetAbsolutePath(Path.Combine(WorkspaceConfigurationStorageService.KiotaDirectorySegment, WorkspaceConfigurationStorageService.ManifestFileName))}#{Configuration.Generation.ClientClassName}"; + DisplayInfoHint(Configuration.Generation.Language, string.Empty, manifestPath); + DisplayGenerateAdvancedHint(includePatterns ?? [], excludePatterns ?? [], string.Empty, manifestPath, "plugin edit"); + return 0; + } + catch (Exception ex) + { +#if DEBUG + logger.LogCritical(ex, "error adding the plugin: {exceptionMessage}", ex.Message); + throw; // so debug tools go straight to the source of the exception when attached +#else + logger.LogCritical("error adding the plugin: {exceptionMessage}", ex.Message); + return 1; +#endif + } + } + } +} diff --git a/src/kiota/Handlers/Plugin/GenerateHandler.cs b/src/kiota/Handlers/Plugin/GenerateHandler.cs new file mode 100644 index 0000000000..db85404d61 --- /dev/null +++ b/src/kiota/Handlers/Plugin/GenerateHandler.cs @@ -0,0 +1,89 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.Json; +using Kiota.Builder; +using Kiota.Builder.Configuration; +using Kiota.Builder.WorkspaceManagement; +using Microsoft.Extensions.Logging; + +namespace kiota.Handlers.Plugin; + +internal class GenerateHandler : BaseKiotaCommandHandler +{ + public required Option ClassOption + { + get; init; + } + public required Option RefreshOption + { + get; init; + } + public override async Task InvokeAsync(InvocationContext context) + { + string className = context.ParseResult.GetValueForOption(ClassOption) ?? string.Empty; + bool refresh = context.ParseResult.GetValueForOption(RefreshOption); + CancellationToken cancellationToken = context.BindingContext.GetService(typeof(CancellationToken)) is CancellationToken token ? token : CancellationToken.None; + var (loggerFactory, logger) = GetLoggerAndFactory(context, Configuration.Generation.OutputPath); + using (loggerFactory) + { + await CheckForNewVersionAsync(logger, cancellationToken).ConfigureAwait(false); + logger.AppendInternalTracing(); + logger.LogTrace("configuration: {configuration}", JsonSerializer.Serialize(Configuration, KiotaConfigurationJsonContext.Default.KiotaConfiguration)); + try + { + var workspaceStorageService = new WorkspaceConfigurationStorageService(Directory.GetCurrentDirectory()); + var (config, manifest) = await workspaceStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false); + if (config == null) + { + DisplayError("The workspace configuration is missing, please run the init command first."); + return 1; + } + var clientNameWasNotProvided = string.IsNullOrEmpty(className); + var clientEntries = config + .Plugins + .Where(x => clientNameWasNotProvided || x.Key.Equals(className, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (clientEntries.Length == 0 && !clientNameWasNotProvided) + { + DisplayError($"No client found with the provided name {className}"); + return 1; + } + foreach (var clientEntry in clientEntries) + { + var generationConfiguration = new GenerationConfiguration(); + var requests = !refresh && manifest is not null && manifest.ApiDependencies.TryGetValue(clientEntry.Key, out var value) ? value.Requests : []; + clientEntry.Value.UpdateGenerationConfigurationFromApiPluginConfiguration(generationConfiguration, clientEntry.Key, requests); + DefaultSerializersAndDeserializers(generationConfiguration); + generationConfiguration.ClearCache = refresh; + generationConfiguration.CleanOutput = refresh; + generationConfiguration.Operation = ConsumerOperation.Generate; + var builder = new KiotaBuilder(logger, generationConfiguration, httpClient, true); + var result = await builder.GeneratePluginAsync(cancellationToken).ConfigureAwait(false); + if (result) + { + DisplaySuccess($"Update of {clientEntry.Key} client completed"); + var manifestPath = $"{GetAbsolutePath(Path.Combine(WorkspaceConfigurationStorageService.KiotaDirectorySegment, WorkspaceConfigurationStorageService.ManifestFileName))}#{clientEntry.Key}"; + DisplayInfoHint(generationConfiguration.Language, string.Empty, manifestPath); + } + else + { + DisplayWarning($"Update of {clientEntry.Key} skipped, no changes detected"); + DisplayCleanHint("client generate", "--refresh"); + } + } + return 0; + } + catch (Exception ex) + { +#if DEBUG + logger.LogCritical(ex, "error adding the client: {exceptionMessage}", ex.Message); + throw; // so debug tools go straight to the source of the exception when attached +#else + logger.LogCritical("error adding the client: {exceptionMessage}", ex.Message); + return 1; +#endif + } + } + throw new NotImplementedException(); + } +} diff --git a/src/kiota/Handlers/Plugin/RemoveHandler.cs b/src/kiota/Handlers/Plugin/RemoveHandler.cs new file mode 100644 index 0000000000..0c0f3c7f5b --- /dev/null +++ b/src/kiota/Handlers/Plugin/RemoveHandler.cs @@ -0,0 +1,46 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using Kiota.Builder; +using Kiota.Builder.WorkspaceManagement; +using Microsoft.Extensions.Logging; + +namespace kiota.Handlers.Plugin; +internal class RemoveHandler : BaseKiotaCommandHandler +{ + public required Option ClassOption + { + get; init; + } + public required Option CleanOutputOption + { + get; init; + } + public override async Task InvokeAsync(InvocationContext context) + { + string className = context.ParseResult.GetValueForOption(ClassOption) ?? string.Empty; + bool cleanOutput = context.ParseResult.GetValueForOption(CleanOutputOption); + CancellationToken cancellationToken = context.BindingContext.GetService(typeof(CancellationToken)) is CancellationToken token ? token : CancellationToken.None; + var (loggerFactory, logger) = GetLoggerAndFactory(context, $"./{DescriptionStorageService.KiotaDirectorySegment}"); + using (loggerFactory) + { + try + { + await CheckForNewVersionAsync(logger, cancellationToken).ConfigureAwait(false); + var workspaceManagementService = new WorkspaceManagementService(logger, httpClient, true); + await workspaceManagementService.RemovePluginAsync(className, cleanOutput, cancellationToken).ConfigureAwait(false); + DisplaySuccess($"Plugin {className} removed successfully!"); + return 0; + } + catch (Exception ex) + { +#if DEBUG + logger.LogCritical(ex, "error removing the plugin: {exceptionMessage}", ex.Message); + throw; // so debug tools go straight to the source of the exception when attached +#else + logger.LogCritical("error removing the plugin: {exceptionMessage}", ex.Message); + return 1; +#endif + } + } + } +} diff --git a/src/kiota/KiotaClientCommands.cs b/src/kiota/KiotaClientCommands.cs index ba3b54fff9..8f08d20de4 100644 --- a/src/kiota/KiotaClientCommands.cs +++ b/src/kiota/KiotaClientCommands.cs @@ -14,7 +14,7 @@ public static Command GetClientNodeCommand() command.AddCommand(GetGenerateCommand()); return command; } - private static Option GetSkipGenerationOption() + internal static Option GetSkipGenerationOption() { var skipGeneration = new Option("--skip-generation", "Skips the generation of the client"); skipGeneration.AddAlias("--sg"); @@ -171,7 +171,7 @@ public static Command GetGenerateCommand() }; return command; } - private static Option GetRefreshOption() + internal static Option GetRefreshOption() { var refresh = new Option("--refresh", "Refreshes the client OpenAPI description before generating the client"); refresh.AddAlias("--r"); diff --git a/src/kiota/KiotaHost.cs b/src/kiota/KiotaHost.cs index 4323e97fcf..5923772de7 100644 --- a/src/kiota/KiotaHost.cs +++ b/src/kiota/KiotaHost.cs @@ -30,6 +30,7 @@ public static RootCommand GetRootCommand() { rootCommand.AddCommand(KiotaWorkspaceCommands.GetWorkspaceNodeCommand()); rootCommand.AddCommand(KiotaClientCommands.GetClientNodeCommand()); + rootCommand.AddCommand(KiotaPluginCommands.GetPluginNodeCommand()); } return rootCommand; } @@ -550,7 +551,7 @@ private static void AddStringRegexValidator(Option option, Regex validat input.ErrorMessage = $"{value} is not a valid {parameterName} for the client, the {parameterName} must conform to {validator}"; }); } - private static void ValidateKnownValues(OptionResult input, string parameterName, IEnumerable knownValues) + internal static void ValidateKnownValues(OptionResult input, string parameterName, IEnumerable knownValues) { var knownValuesHash = new HashSet(knownValues, StringComparer.OrdinalIgnoreCase); if (input.Tokens.Any() && input.Tokens.Select(static x => x.Value).SelectMany(static x => x.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)).FirstOrDefault(x => !knownValuesHash.Contains(x)) is string unknownValue) diff --git a/src/kiota/KiotaPluginCommands.cs b/src/kiota/KiotaPluginCommands.cs new file mode 100644 index 0000000000..4781021c12 --- /dev/null +++ b/src/kiota/KiotaPluginCommands.cs @@ -0,0 +1,144 @@ +using System.CommandLine; +using kiota.Handlers.Plugin; +using Kiota.Builder; +using Kiota.Builder.Configuration; + +namespace kiota; +public static class KiotaPluginCommands +{ + public static Command GetPluginNodeCommand() + { + var command = new Command("plugin", "Manages the Kiota generated API plugins"); + command.AddCommand(GetAddCommand()); + command.AddCommand(GetRemoveCommand()); + command.AddCommand(GetEditCommand()); + command.AddCommand(GetGenerateCommand()); + return command; + } + internal static Option GetPluginNameOption(bool required = true) + { + var clientName = new Option("--plugin-name", "The name of the plugin to manage") + { + IsRequired = required, + }; + clientName.AddAlias("--pn"); + return clientName; + } + internal static Option> GetPluginTypeOption(bool isRequired = true) + { + var typeOption = new Option>("--type", "The type of manifest to generate. Accepts multiple values."); + typeOption.AddAlias("-t"); + if (isRequired) + { + typeOption.IsRequired = true; + typeOption.Arity = ArgumentArity.OneOrMore; + } + typeOption.AddValidator(x => KiotaHost.ValidateKnownValues(x, "type", Enum.GetNames())); + return typeOption; + } + public static Command GetAddCommand() + { + var defaultConfiguration = new GenerationConfiguration(); + var outputOption = KiotaHost.GetOutputPathOption(defaultConfiguration.OutputPath); + var descriptionOption = KiotaHost.GetDescriptionOption(defaultConfiguration.OpenAPIFilePath, true); + var (includePatterns, excludePatterns) = KiotaHost.GetIncludeAndExcludeOptions(defaultConfiguration.IncludePatterns, defaultConfiguration.ExcludePatterns); + var logLevelOption = KiotaHost.GetLogLevelOption(); + var skipGenerationOption = KiotaClientCommands.GetSkipGenerationOption(); + var pluginNameOption = GetPluginNameOption(); + var pluginType = GetPluginTypeOption(); + var command = new Command("add", "Adds a new plugin to the Kiota configuration"){ + descriptionOption, + includePatterns, + excludePatterns, + logLevelOption, + skipGenerationOption, + outputOption, + pluginNameOption, + pluginType, + //TODO overlay when we have support for it in OAI.net + }; + command.Handler = new AddHandler + { + ClassOption = pluginNameOption, + OutputOption = outputOption, + PluginTypesOption = pluginType, + DescriptionOption = descriptionOption, + IncludePatternsOption = includePatterns, + ExcludePatternsOption = excludePatterns, + SkipGenerationOption = skipGenerationOption, + LogLevelOption = logLevelOption, + }; + return command; + } + public static Command GetEditCommand() + { + var outputOption = KiotaHost.GetOutputPathOption(string.Empty); + var descriptionOption = KiotaHost.GetDescriptionOption(string.Empty); + var (includePatterns, excludePatterns) = KiotaHost.GetIncludeAndExcludeOptions([], []); + var logLevelOption = KiotaHost.GetLogLevelOption(); + var skipGenerationOption = KiotaClientCommands.GetSkipGenerationOption(); + var pluginNameOption = GetPluginNameOption(); + var pluginTypes = GetPluginTypeOption(false); + var command = new Command("edit", "Edits a plugin configuration and updates the Kiota configuration"){ + descriptionOption, + includePatterns, + excludePatterns, + logLevelOption, + skipGenerationOption, + outputOption, + pluginNameOption, + pluginTypes, + //TODO overlay when we have support for it in OAI.net + }; + command.Handler = new EditHandler + { + ClassOption = pluginNameOption, + OutputOption = outputOption, + PluginTypesOption = pluginTypes, + DescriptionOption = descriptionOption, + IncludePatternsOption = includePatterns, + ExcludePatternsOption = excludePatterns, + SkipGenerationOption = skipGenerationOption, + LogLevelOption = logLevelOption, + }; + return command; + } + public static Command GetRemoveCommand() + { + var pluginNameOption = GetPluginNameOption(); + var cleanOutputOption = KiotaHost.GetCleanOutputOption(false); + var logLevelOption = KiotaHost.GetLogLevelOption(); + var command = new Command("remove", "Removes a plugin from the Kiota configuration") + { + pluginNameOption, + cleanOutputOption, + logLevelOption, + }; + command.Handler = new RemoveHandler + { + ClassOption = pluginNameOption, + CleanOutputOption = cleanOutputOption, + LogLevelOption = logLevelOption, + }; + return command; + } + public static Command GetGenerateCommand() + { + var pluginNameOption = GetPluginNameOption(); + var logLevelOption = KiotaHost.GetLogLevelOption(); + var refreshOption = KiotaClientCommands.GetRefreshOption(); + var command = new Command("generate", "Generates one or all plugin from the Kiota configuration") + { + pluginNameOption, + logLevelOption, + refreshOption, + }; + command.Handler = new GenerateHandler + { + ClassOption = pluginNameOption, + LogLevelOption = logLevelOption, + RefreshOption = refreshOption, + }; + return command; + } +} diff --git a/src/kiota/kiota.csproj b/src/kiota/kiota.csproj index bd261b9694..68e3fec832 100644 --- a/src/kiota/kiota.csproj +++ b/src/kiota/kiota.csproj @@ -46,7 +46,7 @@ - + diff --git a/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiAiReasoningInstructionsExtensionTests.cs b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiAiReasoningInstructionsExtensionTests.cs new file mode 100644 index 0000000000..1a4d716b51 --- /dev/null +++ b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiAiReasoningInstructionsExtensionTests.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Kiota.Builder.Configuration; +using Kiota.Builder.OpenApiExtensions; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Writers; +using Moq; +using Xunit; + +namespace Kiota.Builder.Tests.OpenApiExtensions; +public sealed class OpenApiAiReasoningInstructionsExtensionTests : IDisposable +{ + private readonly HttpClient _httpClient = new(); + public void Dispose() + { + _httpClient.Dispose(); + } + [Fact] + public void Parses() + { + var oaiValue = new OpenApiArray { + new OpenApiString("This is a description"), + new OpenApiString("This is a description 2"), + }; + var value = OpenApiAiReasoningInstructionsExtension.Parse(oaiValue); + Assert.NotNull(value); + Assert.Equal("This is a description", value.ReasoningInstructions[0]); + } + private readonly string TempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + [Fact] + public async Task ParsesInDocument() + { + var documentContent = @"openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + x-ai-reasoning-instructions: + - This is a description + - This is a description 2"; + Directory.CreateDirectory(TempDirectory); + var documentPath = Path.Combine(TempDirectory, "document.yaml"); + await File.WriteAllTextAsync(documentPath, documentContent); + var mockLogger = new Mock>(); + var documentDownloadService = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object); + var generationConfig = new GenerationConfiguration { OutputPath = TempDirectory }; + var (openApiDocumentStream, _) = await documentDownloadService.LoadStreamAsync(documentPath, generationConfig); + var document = await documentDownloadService.GetDocumentFromStreamAsync(openApiDocumentStream, generationConfig); + Assert.NotNull(document); + Assert.NotNull(document.Info); + Assert.True(document.Info.Extensions.TryGetValue(OpenApiAiReasoningInstructionsExtension.Name, out var descriptionExtension)); + Assert.IsType(descriptionExtension); + Assert.Equal("This is a description", ((OpenApiAiReasoningInstructionsExtension)descriptionExtension).ReasoningInstructions[0]); + } + [Fact] + public void Serializes() + { + var value = new OpenApiAiReasoningInstructionsExtension + { + ReasoningInstructions = [ + "This is a description", + "This is a description 2", + ] + }; + using var sWriter = new StringWriter(); + OpenApiJsonWriter writer = new(sWriter, new OpenApiJsonWriterSettings { Terse = true }); + + + value.Write(writer, OpenApiSpecVersion.OpenApi3_0); + var result = sWriter.ToString(); + Assert.Equal("[\"This is a description\",\"This is a description 2\"]", result); + } +} diff --git a/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiAiRespondingInstructionsExtensionTests.cs b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiAiRespondingInstructionsExtensionTests.cs new file mode 100644 index 0000000000..ba82ff57e7 --- /dev/null +++ b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiAiRespondingInstructionsExtensionTests.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Kiota.Builder.Configuration; +using Kiota.Builder.OpenApiExtensions; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Writers; +using Moq; +using Xunit; + +namespace Kiota.Builder.Tests.OpenApiExtensions; +public sealed class OpenApiAiRespondingInstructionsExtensionTests : IDisposable +{ + private readonly HttpClient _httpClient = new(); + public void Dispose() + { + _httpClient.Dispose(); + } + [Fact] + public void Parses() + { + var oaiValue = new OpenApiArray { + new OpenApiString("This is a description"), + new OpenApiString("This is a description 2"), + }; + var value = OpenApiAiRespondingInstructionsExtension.Parse(oaiValue); + Assert.NotNull(value); + Assert.Equal("This is a description", value.RespondingInstructions[0]); + } + private readonly string TempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + [Fact] + public async Task ParsesInDocument() + { + var documentContent = @"openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + x-ai-responding-instructions: + - This is a description + - This is a description 2"; + Directory.CreateDirectory(TempDirectory); + var documentPath = Path.Combine(TempDirectory, "document.yaml"); + await File.WriteAllTextAsync(documentPath, documentContent); + var mockLogger = new Mock>(); + var documentDownloadService = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object); + var generationConfig = new GenerationConfiguration { OutputPath = TempDirectory }; + var (openApiDocumentStream, _) = await documentDownloadService.LoadStreamAsync(documentPath, generationConfig); + var document = await documentDownloadService.GetDocumentFromStreamAsync(openApiDocumentStream, generationConfig); + Assert.NotNull(document); + Assert.NotNull(document.Info); + Assert.True(document.Info.Extensions.TryGetValue(OpenApiAiRespondingInstructionsExtension.Name, out var descriptionExtension)); + Assert.IsType(descriptionExtension); + Assert.Equal("This is a description", ((OpenApiAiRespondingInstructionsExtension)descriptionExtension).RespondingInstructions[0]); + } + [Fact] + public void Serializes() + { + var value = new OpenApiAiRespondingInstructionsExtension + { + RespondingInstructions = [ + "This is a description", + "This is a description 2", + ] + }; + using var sWriter = new StringWriter(); + OpenApiJsonWriter writer = new(sWriter, new OpenApiJsonWriterSettings { Terse = true }); + + + value.Write(writer, OpenApiSpecVersion.OpenApi3_0); + var result = sWriter.ToString(); + Assert.Equal("[\"This is a description\",\"This is a description 2\"]", result); + } +} diff --git a/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiDescriptionForModelExtensionTests.cs b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiDescriptionForModelExtensionTests.cs new file mode 100644 index 0000000000..f66cc5e28e --- /dev/null +++ b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiDescriptionForModelExtensionTests.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Kiota.Builder.Configuration; +using Kiota.Builder.OpenApiExtensions; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Writers; +using Moq; +using Xunit; + +namespace Kiota.Builder.Tests.OpenApiExtensions; +public sealed class OpenApiDescriptionForModelExtensionTests : IDisposable +{ + private readonly HttpClient _httpClient = new(); + public void Dispose() + { + _httpClient.Dispose(); + } + [Fact] + public void Parses() + { + var oaiValue = new OpenApiString("This is a description"); + var value = OpenApiDescriptionForModelExtension.Parse(oaiValue); + Assert.NotNull(value); + Assert.Equal("This is a description", value.Description); + } + private readonly string TempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + [Fact] + public async Task ParsesInDocument() + { + var documentContent = @"openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + x-ai-description: This is a description"; + Directory.CreateDirectory(TempDirectory); + var documentPath = Path.Combine(TempDirectory, "document.yaml"); + await File.WriteAllTextAsync(documentPath, documentContent); + var mockLogger = new Mock>(); + var documentDownloadService = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object); + var generationConfig = new GenerationConfiguration { OutputPath = TempDirectory }; + var (openApiDocumentStream, _) = await documentDownloadService.LoadStreamAsync(documentPath, generationConfig); + var document = await documentDownloadService.GetDocumentFromStreamAsync(openApiDocumentStream, generationConfig); + Assert.NotNull(document); + Assert.NotNull(document.Info); + Assert.True(document.Info.Extensions.TryGetValue(OpenApiDescriptionForModelExtension.Name, out var descriptionExtension)); + Assert.IsType(descriptionExtension); + Assert.Equal("This is a description", ((OpenApiDescriptionForModelExtension)descriptionExtension).Description); + } + [Fact] + public void Serializes() + { + var value = new OpenApiDescriptionForModelExtension + { + Description = "This is a description", + }; + using var sWriter = new StringWriter(); + OpenApiJsonWriter writer = new(sWriter, new OpenApiJsonWriterSettings { Terse = true }); + + + value.Write(writer, OpenApiSpecVersion.OpenApi3_0); + var result = sWriter.ToString(); + Assert.Equal("\"This is a description\"", result); + } +} diff --git a/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiKiotaExtensionTests.cs b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiKiotaExtensionTests.cs index 56bac02da8..27096a9862 100644 --- a/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiKiotaExtensionTests.cs +++ b/tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiKiotaExtensionTests.cs @@ -36,13 +36,13 @@ public void Serializes() } }, }; - using TextWriter sWriter = new StringWriter(); - OpenApiJsonWriter writer = new(sWriter); + using var sWriter = new StringWriter(); + OpenApiJsonWriter writer = new(sWriter, new OpenApiJsonWriterSettings { Terse = true }); value.Write(writer, OpenApiSpecVersion.OpenApi3_0); var result = sWriter.ToString(); - Assert.Equal("{\n \"languagesInformation\": {\n \"CSharp\": {\n \"maturityLevel\": \"Preview\",\n \"dependencyInstallCommand\": \"dotnet add package\",\n \"dependencies\": [\n {\n \"name\": \"Microsoft.Graph.Core\",\n \"version\": \"1.0.0\"\n }\n ],\n \"clientClassName\": \"GraphServiceClient\",\n \"clientNamespaceName\": \"Microsoft.Graph\",\n \"structuredMimeTypes\": [\n \"application/json\",\n \"application/xml\"\n ]\n }\n }\n}", result); + Assert.Equal("{\"languagesInformation\":{\"CSharp\":{\"maturityLevel\":\"Preview\",\"dependencyInstallCommand\":\"dotnet add package\",\"dependencies\":[{\"name\":\"Microsoft.Graph.Core\",\"version\":\"1.0.0\"}],\"clientClassName\":\"GraphServiceClient\",\"clientNamespaceName\":\"Microsoft.Graph\",\"structuredMimeTypes\":[\"application/json\",\"application/xml\"]}}}", result); } [Fact] public void Parses() diff --git a/tests/Kiota.Builder.Tests/Plugins/OpenAPIRuntimeComparerTests.cs b/tests/Kiota.Builder.Tests/Plugins/OpenAPIRuntimeComparerTests.cs new file mode 100644 index 0000000000..97808aa1c9 --- /dev/null +++ b/tests/Kiota.Builder.Tests/Plugins/OpenAPIRuntimeComparerTests.cs @@ -0,0 +1,24 @@ +using Kiota.Builder.Plugins; +using Microsoft.Plugins.Manifest; +using Xunit; + +namespace Kiota.Builder.Tests.Plugins; +public class OpenAPIRuntimeComparerTests +{ + private readonly OpenAPIRuntimeComparer _comparer = new(); + [Fact] + public void Defensive() + { + Assert.Equal(0, _comparer.GetHashCode(null)); + Assert.True(_comparer.Equals(null, null)); + Assert.False(_comparer.Equals(new(), null)); + Assert.False(_comparer.Equals(null, new())); + } + [Fact] + public void GetsHashCode() + { + var runtime1 = new OpenApiRuntime { Spec = new() { Url = "url", ApiDescription = "description" } }; + var runtime2 = new OpenApiRuntime { Spec = new() { Url = "url", ApiDescription = "description" }, Auth = new AnonymousAuth() }; + Assert.NotEqual(_comparer.GetHashCode(runtime1), _comparer.GetHashCode(runtime2)); + } +} diff --git a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs new file mode 100644 index 0000000000..77e2b4a638 --- /dev/null +++ b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Kiota.Builder.Configuration; +using Kiota.Builder.Plugins; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Services; +using Moq; +using Xunit; + +namespace Kiota.Builder.Tests.Plugins; +public sealed class PluginsGenerationServiceTests : IDisposable +{ + private readonly HttpClient _httpClient = new(); + [Fact] + public void Defensive() + { + Assert.Throws(() => new PluginsGenerationService(null, OpenApiUrlTreeNode.Create(), new())); + Assert.Throws(() => new PluginsGenerationService(new(), null, new())); + Assert.Throws(() => new PluginsGenerationService(new(), OpenApiUrlTreeNode.Create(), null)); + } + + public void Dispose() + { + _httpClient.Dispose(); + } + + [Fact] + public async Task GeneratesManifest() + { + var simpleDescriptionContent = @"openapi: 3.0.0 +info: + title: test + version: 1.0 +servers: + - url: http://localhost/ + description: There's no place like home +paths: + /test: + get: + operationId: test + responses: + '200': + description: test"; + var simpleDescriptionPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".yaml"; + await File.WriteAllTextAsync(simpleDescriptionPath, simpleDescriptionContent); + var mockLogger = new Mock>(); + var openAPIDocumentDS = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object); + var outputDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var generationConfiguration = new GenerationConfiguration + { + OutputPath = outputDirectory, + OpenAPIFilePath = "openapiPath", + PluginTypes = [PluginType.Microsoft, PluginType.APIManifest], + ClientClassName = "client", + 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); + var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel); + + var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration); + await pluginsGenerationService.GenerateManifestAsync(); + + Assert.True(File.Exists(Path.Combine(outputDirectory, "client-microsoft.json"))); + Assert.True(File.Exists(Path.Combine(outputDirectory, "client-apimanifest.json"))); + Assert.True(File.Exists(Path.Combine(outputDirectory, "openapi.yml"))); + } +} diff --git a/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationComparerTests.cs b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationComparerTests.cs index d7476f9923..013f0a4da7 100644 --- a/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationComparerTests.cs +++ b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationComparerTests.cs @@ -16,6 +16,6 @@ public void Defensive() [Fact] public void GetsHashCode() { - Assert.Equal(13, _comparer.GetHashCode(new() { UsesBackingStore = true })); + Assert.Equal(17, _comparer.GetHashCode(new() { UsesBackingStore = true })); } } diff --git a/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiPluginConfigurationComparerTests.cs b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiPluginConfigurationComparerTests.cs new file mode 100644 index 0000000000..78d7b2a9c3 --- /dev/null +++ b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiPluginConfigurationComparerTests.cs @@ -0,0 +1,14 @@ +using Kiota.Builder.WorkspaceManagement; +using Xunit; + +namespace Kiota.Builder.Tests.WorkspaceManagement; + +public class ApiPluginConfigurationComparerTests +{ + private readonly ApiPluginConfigurationComparer _comparer = new(); + [Fact] + public void GetsHashCode() + { + Assert.NotEqual(_comparer.GetHashCode(new() { Types = ["OpenAI"] }), _comparer.GetHashCode(new() { Types = ["APIManifest"] })); + } +} diff --git a/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiPluginConfigurationTests.cs b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiPluginConfigurationTests.cs new file mode 100644 index 0000000000..e9c51e6fa3 --- /dev/null +++ b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiPluginConfigurationTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using Kiota.Builder.Configuration; +using Kiota.Builder.WorkspaceManagement; +using Xunit; + +namespace Kiota.Builder.Tests.WorkspaceManagement; +public sealed class ApiPluginConfigurationTests +{ + [Fact] + public void Defensive() + { + Assert.Throws(() => new ApiPluginConfiguration(null)); + } + [Fact] + public void CopiesPluginTypesFromConfiguration() + { + var generationConfig = new GenerationConfiguration + { + PluginTypes = [PluginType.APIManifest] + }; + var apiPluginConfig = new ApiPluginConfiguration(generationConfig); + Assert.NotNull(apiPluginConfig); + Assert.Contains("APIManifest", apiPluginConfig.Types); + } + [Fact] + public void Clones() + { + var apiPluginConfig = new ApiPluginConfiguration + { + Types = ["APIManifest"] + }; + var cloned = (ApiPluginConfiguration)apiPluginConfig.Clone(); + Assert.NotNull(cloned); + Assert.Equal(apiPluginConfig.Types, cloned.Types); + } + [Fact] + public void UpdateGenerationConfigurationFromPluginConfiguration() + { + var generationConfig = new GenerationConfiguration(); + var apiPluginConfig = new ApiPluginConfiguration + { + Types = ["APIManifest"] + }; + apiPluginConfig.UpdateGenerationConfigurationFromApiPluginConfiguration(generationConfig, "Foo"); + Assert.Contains(PluginType.APIManifest, generationConfig.PluginTypes); + } +} diff --git a/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs b/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs index f862bd24a7..584bc57c06 100644 --- a/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs +++ b/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs @@ -31,7 +31,7 @@ public async Task IsClientPresentReturnsFalseOnNoClient(bool usesConfig) var mockLogger = Mock.Of(); Directory.CreateDirectory(tempPath); var service = new WorkspaceManagementService(mockLogger, httpClient, usesConfig, tempPath); - var result = await service.IsClientPresent("clientName"); + var result = await service.IsConsumerPresent("clientName"); Assert.False(result); } [InlineData(true, true)] @@ -90,7 +90,27 @@ public async Task RemovesAClient() Directory.CreateDirectory(tempPath); await service.UpdateStateFromConfigurationAsync(configuration, "foo", [], Stream.Null); await service.RemoveClientAsync("clientName"); - var result = await service.IsClientPresent("clientName"); + var result = await service.IsConsumerPresent("clientName"); + Assert.False(result); + } + [Fact] + public async Task RemovesAPlugin() + { + var mockLogger = Mock.Of(); + Directory.CreateDirectory(tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath); + var configuration = new GenerationConfiguration + { + ClientClassName = "clientName", + OutputPath = tempPath, + OpenAPIFilePath = Path.Combine(tempPath, "openapi.yaml"), + ApiRootUrl = "https://graph.microsoft.com", + PluginTypes = [PluginType.APIManifest], + }; + Directory.CreateDirectory(tempPath); + await service.UpdateStateFromConfigurationAsync(configuration, "foo", [], Stream.Null); + await service.RemovePluginAsync("clientName"); + var result = await service.IsConsumerPresent("clientName"); Assert.False(result); } [Fact]