From 76fa86fbdb76817327d863e445b94f0f78564a6a Mon Sep 17 00:00:00 2001 From: Caleb Kiage <747955+calebkiage@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:30:22 +0300 Subject: [PATCH] add auth into plugin generation (#5209) * add auth into plugin generation * add http bearer security allow falling back to the global security if the operation has none. * fix compile error * refactor exception to its own file * fix security scheme reference id generation * update tests to check for the root security object. * allow configuring plugin manifest's auth * apply formatter * add changelog entry * refactor local function add tests * fix lint error * fix typo in test data * update code docs * fix issue with incorrect auth type when security schemes component does not contain the provided authentication * add auth information to workspace management * fix format * fix test failure * update hashcode * improve hash calculation --- CHANGELOG.md | 1 + .../Configuration/GenerationConfiguration.cs | 10 + .../Configuration/PluginAuthConfiguration.cs | 47 +++ .../Configuration/PluginAuthType.cs | 16 + .../Plugins/PluginsGenerationService.cs | 100 +++++-- .../UnsupportedSecuritySchemeException.cs | 28 ++ .../ApiPluginConfiguration.cs | 25 ++ .../ApiPluginConfigurationComparer.cs | 14 +- .../Plugins/PluginAuthConfigurationTests.cs | 47 +++ .../Plugins/PluginsGenerationServiceTests.cs | 281 ++++++++++++++++++ 10 files changed, 542 insertions(+), 27 deletions(-) create mode 100644 src/Kiota.Builder/Configuration/PluginAuthConfiguration.cs create mode 100644 src/Kiota.Builder/Configuration/PluginAuthType.cs create mode 100644 src/Kiota.Builder/Plugins/UnsupportedSecuritySchemeException.cs create mode 100644 tests/Kiota.Builder.Tests/Plugins/PluginAuthConfigurationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a2451ce4..797af6deba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the ability to export the CodeDom to a file showing the public APIs to be generated in a given language [#4627](https://github.com/microsoft/kiota/issues/4627) - Added composed type serialization in Typescript [2462](https://github.com/microsoft/kiota/issues/2462) +- Use authentication information available in the source OpenAPI document when generating a plugin manifest. [#5070](https://github.com/microsoft/kiota/issues/5070) ### Changed diff --git a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs index 52fc3f1965..6e81b63f23 100644 --- a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs +++ b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs @@ -8,6 +8,7 @@ using Microsoft.OpenApi.ApiManifest; namespace Kiota.Builder.Configuration; + #pragma warning disable CA2227 #pragma warning disable CA1056 public class GenerationConfiguration : ICloneable @@ -157,6 +158,7 @@ public object Clone() PluginTypes = new(PluginTypes ?? Enumerable.Empty()), DisableSSLValidation = DisableSSLValidation, ExportPublicApi = ExportPublicApi, + PluginAuthInformation = PluginAuthInformation, }; } private static readonly StringIEnumerableDeepComparer comparer = new(); @@ -211,6 +213,14 @@ public bool DisableSSLValidation { get; set; } + + /// + /// Authentication information to be used when generating the plugin manifest. + /// + public PluginAuthConfiguration? PluginAuthInformation + { + get; set; + } } #pragma warning restore CA1056 #pragma warning restore CA2227 diff --git a/src/Kiota.Builder/Configuration/PluginAuthConfiguration.cs b/src/Kiota.Builder/Configuration/PluginAuthConfiguration.cs new file mode 100644 index 0000000000..cce36655b3 --- /dev/null +++ b/src/Kiota.Builder/Configuration/PluginAuthConfiguration.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.Plugins.Manifest; + +namespace Kiota.Builder.Configuration; + +/// +/// Auth information used in generated plugin manifest +/// +public class PluginAuthConfiguration +{ + /// + /// Auth information used in generated plugin manifest + /// + /// The auth reference id + /// If the reference id is null or contains only whitespaces. + public PluginAuthConfiguration(string referenceId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(referenceId); + ReferenceId = referenceId; + } + + /// + /// The Teams Toolkit compatible plugin auth type. + /// + public PluginAuthType AuthType + { + get; set; + } + + /// + /// The Teams Toolkit plugin auth reference id + /// + public string ReferenceId + { + get; set; + } + + internal Auth ToPluginManifestAuth() + { + return AuthType switch + { + PluginAuthType.OAuthPluginVault => new OAuthPluginVault { ReferenceId = ReferenceId }, + PluginAuthType.ApiKeyPluginVault => new ApiKeyPluginVault { ReferenceId = ReferenceId }, + _ => throw new ArgumentOutOfRangeException(nameof(AuthType), $"Unknown plugin auth type '{AuthType}'") + }; + } +} diff --git a/src/Kiota.Builder/Configuration/PluginAuthType.cs b/src/Kiota.Builder/Configuration/PluginAuthType.cs new file mode 100644 index 0000000000..8f7e31c14d --- /dev/null +++ b/src/Kiota.Builder/Configuration/PluginAuthType.cs @@ -0,0 +1,16 @@ +namespace Kiota.Builder.Configuration; + +/// +/// Supported plugin types +/// +public enum PluginAuthType +{ + /// + /// OAuth authentication + /// + OAuthPluginVault, + /// + /// API key, HTTP Bearer token or OpenId Connect authentication + /// + ApiKeyPluginVault +} diff --git a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs index 394d4a274d..f4af6c1f42 100644 --- a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs +++ b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs @@ -12,12 +12,14 @@ using Kiota.Builder.OpenApiExtensions; using Microsoft.Kiota.Abstractions.Extensions; using Microsoft.OpenApi.ApiManifest; +using Microsoft.OpenApi.Extensions; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Services; using Microsoft.OpenApi.Writers; using Microsoft.Plugins.Manifest; namespace Kiota.Builder.Plugins; + public partial class PluginsGenerationService { private readonly OpenApiDocument OAIDocument; @@ -25,7 +27,8 @@ public partial class PluginsGenerationService private readonly GenerationConfiguration Configuration; private readonly string WorkingDirectory; - public PluginsGenerationService(OpenApiDocument document, OpenApiUrlTreeNode openApiUrlTreeNode, GenerationConfiguration configuration, string workingDirectory) + public PluginsGenerationService(OpenApiDocument document, OpenApiUrlTreeNode openApiUrlTreeNode, + GenerationConfiguration configuration, string workingDirectory) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(openApiUrlTreeNode); @@ -36,13 +39,15 @@ public PluginsGenerationService(OpenApiDocument document, OpenApiUrlTreeNode ope Configuration = configuration; WorkingDirectory = workingDirectory; } + private static readonly OpenAPIRuntimeComparer _openAPIRuntimeComparer = new(); private const string ManifestFileNameSuffix = ".json"; private const string DescriptionPathSuffix = "openapi.yml"; public async Task GenerateManifestAsync(CancellationToken cancellationToken = default) { // 1. cleanup any namings to be used later on. - Configuration.ClientClassName = PluginNameCleanupRegex().Replace(Configuration.ClientClassName, string.Empty); //drop any special characters + Configuration.ClientClassName = + PluginNameCleanupRegex().Replace(Configuration.ClientClassName, string.Empty); //drop any special characters // 2. write the OpenApi description var descriptionRelativePath = $"{Configuration.ClientClassName.ToLowerInvariant()}-{DescriptionPathSuffix}"; var descriptionFullPath = Path.Combine(Configuration.OutputPath, descriptionRelativePath); @@ -94,6 +99,7 @@ public async Task GenerateManifestAsync(CancellationToken cancellationToken = de default: throw new NotImplementedException($"The {pluginType} plugin is not implemented."); } + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); } } @@ -119,7 +125,9 @@ private OpenApiDocument GetDocumentWithTrimmedComponentsAndResponses(OpenApiDocu var basePath = doc.GetAPIRootUrl(Configuration.OpenAPIFilePath); foreach (var path in doc.Paths.Where(static path => path.Value.Operations.Count > 0)) { - var key = string.IsNullOrEmpty(basePath) ? path.Key : $"{basePath}/{path.Key.TrimStart(KiotaBuilder.ForwardSlash)}"; + var key = string.IsNullOrEmpty(basePath) + ? path.Key + : $"{basePath}/{path.Key.TrimStart(KiotaBuilder.ForwardSlash)}"; requestUrls[key] = path.Value.Operations.Keys.Select(static key => key.ToString().ToUpperInvariant()).ToList(); } @@ -129,7 +137,7 @@ private OpenApiDocument GetDocumentWithTrimmedComponentsAndResponses(OpenApiDocu private PluginManifestDocument GetManifestDocument(string openApiDocumentPath) { - var (runtimes, functions) = GetRuntimesAndFunctionsFromTree(TreeNode, openApiDocumentPath); + var (runtimes, functions) = GetRuntimesAndFunctionsFromTree(OAIDocument, Configuration.PluginAuthInformation, TreeNode, openApiDocumentPath); var descriptionForHuman = OAIDocument.Info?.Description.CleanupXMLString() is string d && !string.IsNullOrEmpty(d) ? d : $"Description for {OAIDocument.Info?.Title.CleanupXMLString()}"; var manifestInfo = ExtractInfoFromDocument(OAIDocument.Info); return new PluginManifestDocument @@ -184,69 +192,107 @@ descriptionExtension is OpenApiDescriptionForModelExtension extension && privacyUrl = privacy.Privacy; return new OpenApiManifestInfo(descriptionForModel, legalUrl, logoUrl, privacyUrl, contactEmail); - } + private const string DefaultContactName = "publisher-name"; private const string DefaultContactEmail = "publisher-email@example.com"; - private sealed record OpenApiManifestInfo(string? DescriptionForModel = null, string? LegalUrl = null, string? LogoUrl = null, string? PrivacyUrl = null, string ContactEmail = DefaultContactEmail); - private static (OpenApiRuntime[], Function[]) GetRuntimesAndFunctionsFromTree(OpenApiUrlTreeNode currentNode, string openApiDocumentPath) + + private sealed record OpenApiManifestInfo( + string? DescriptionForModel = null, + string? LegalUrl = null, + string? LogoUrl = null, + string? PrivacyUrl = null, + string ContactEmail = DefaultContactEmail); + + private static (OpenApiRuntime[], Function[]) GetRuntimesAndFunctionsFromTree(OpenApiDocument document, PluginAuthConfiguration? authInformation, OpenApiUrlTreeNode currentNode, + string openApiDocumentPath) { var runtimes = new List(); var functions = new List(); + var configAuth = authInformation?.ToPluginManifestAuth(); 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, - }, + // Configuration overrides document information + Auth = configAuth ?? GetAuth(operation.Security ?? document.SecurityRequirements), + Spec = new OpenApiRuntimeSpec { Url = openApiDocumentPath, }, RunForFunctions = [operation.OperationId] }); functions.Add(new Function { Name = operation.OperationId, Description = - operation.Summary.CleanupXMLString() is string summary && !string.IsNullOrEmpty(summary) + operation.Summary.CleanupXMLString() is { } summary && !string.IsNullOrEmpty(summary) ? summary : operation.Description.CleanupXMLString(), States = GetStatesFromOperation(operation), }); } } + foreach (var node in currentNode.Children) { - var (childRuntimes, childFunctions) = GetRuntimesAndFunctionsFromTree(node.Value, openApiDocumentPath); + var (childRuntimes, childFunctions) = GetRuntimesAndFunctionsFromTree(document, authInformation, node.Value, openApiDocumentPath); runtimes.AddRange(childRuntimes); functions.AddRange(childFunctions); } + return (runtimes.ToArray(), functions.ToArray()); } - private static States? GetStatesFromOperation(OpenApiOperation openApiOperation) + + private static Auth GetAuth(IList securityRequirements) { - return (GetStateFromExtension(openApiOperation, OpenApiAiReasoningInstructionsExtension.Name, static x => x.ReasoningInstructions), - GetStateFromExtension(openApiOperation, OpenApiAiRespondingInstructionsExtension.Name, static x => x.RespondingInstructions)) switch + // Only one security object is allowed + var security = securityRequirements.SingleOrDefault(); + var opSecurity = security?.Keys.SingleOrDefault(); + return (opSecurity is null || opSecurity.UnresolvedReference) ? new AnonymousAuth() : GetAuthFromSecurityScheme(opSecurity); + } + + private static Auth GetAuthFromSecurityScheme(OpenApiSecurityScheme securityScheme) + { + string name = securityScheme.Reference.Id; + return securityScheme.Type switch { - (State reasoning, State responding) => new States + SecuritySchemeType.ApiKey => new ApiKeyPluginVault { - Reasoning = reasoning, - Responding = responding + ReferenceId = $"{{{name}_REGISTRATION_ID}}" }, - (State reasoning, _) => new States + // Only Http bearer is supported + SecuritySchemeType.Http when securityScheme.Scheme.Equals("bearer", StringComparison.OrdinalIgnoreCase) => + new ApiKeyPluginVault { ReferenceId = $"{{{name}_REGISTRATION_ID}}" }, + SecuritySchemeType.OpenIdConnect => new ApiKeyPluginVault { - Reasoning = reasoning + ReferenceId = $"{{{name}_REGISTRATION_ID}}" }, - (_, State responding) => new States + SecuritySchemeType.OAuth2 => new OAuthPluginVault { - Responding = responding + ReferenceId = $"{{{name}_CONFIGURATION_ID}}" }, + _ => throw new UnsupportedSecuritySchemeException(["Bearer Token", "Api Key", "OpenId Connect", "OAuth"], + $"Unsupported security scheme type '{securityScheme.Type}'.") + }; + } + + 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) + + private static State? GetStateFromExtension(OpenApiOperation openApiOperation, string extensionName, + Func> instructionsExtractor) { if (openApiOperation.Extensions.TryGetValue(extensionName, out var rExtRaw) && rExtRaw is T rExt && @@ -254,9 +300,11 @@ rExtRaw is T rExt && { return new State { - Instructions = new Instructions(instructionsExtractor(rExt).Where(static x => !string.IsNullOrEmpty(x)).Select(static x => x.CleanupXMLString()).ToList()) + Instructions = new Instructions(instructionsExtractor(rExt) + .Where(static x => !string.IsNullOrEmpty(x)).Select(static x => x.CleanupXMLString()).ToList()) }; } + return null; } } diff --git a/src/Kiota.Builder/Plugins/UnsupportedSecuritySchemeException.cs b/src/Kiota.Builder/Plugins/UnsupportedSecuritySchemeException.cs new file mode 100644 index 0000000000..ec8d5d28d2 --- /dev/null +++ b/src/Kiota.Builder/Plugins/UnsupportedSecuritySchemeException.cs @@ -0,0 +1,28 @@ +using System; + +namespace Kiota.Builder.Plugins; + +public class UnsupportedSecuritySchemeException(string[] supportedTypes, string? message, Exception? innerException) + : Exception(message, innerException) +{ +#pragma warning disable CA1819 + public string[] SupportedTypes => supportedTypes; +#pragma warning restore CA1819 + + public UnsupportedSecuritySchemeException(string[] supportedTypes, string? message) : this(supportedTypes, message, + null) + { + } + + public UnsupportedSecuritySchemeException() : this(null) + { + } + + public UnsupportedSecuritySchemeException(string? message) : this(message, null) + { + } + + public UnsupportedSecuritySchemeException(string? message, Exception? innerException) : this([], message, innerException) + { + } +} diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfiguration.cs b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfiguration.cs index 2999501447..a4394aaf38 100644 --- a/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfiguration.cs +++ b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfiguration.cs @@ -3,6 +3,7 @@ using System.Linq; using Kiota.Builder.Configuration; using Microsoft.OpenApi.ApiManifest; +using Microsoft.Plugins.Manifest; namespace Kiota.Builder.WorkspaceManagement; @@ -24,12 +25,29 @@ public ApiPluginConfiguration(GenerationConfiguration config) : base(config) { ArgumentNullException.ThrowIfNull(config); Types = config.PluginTypes.Select(x => x.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + AuthType = config.PluginAuthInformation?.AuthType.ToString(); + AuthReferenceId = config.PluginAuthInformation?.ReferenceId; } public HashSet Types { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public string? AuthType + { + get; + set; + } + + public string? AuthReferenceId + { + get; + set; + } + public object Clone() { var result = new ApiPluginConfiguration() { + AuthType = AuthType, + AuthReferenceId = AuthReferenceId, Types = new HashSet(Types, StringComparer.OrdinalIgnoreCase) }; CloneBase(result); @@ -46,6 +64,13 @@ public void UpdateGenerationConfigurationFromApiPluginConfiguration(GenerationCo ArgumentNullException.ThrowIfNull(config); ArgumentException.ThrowIfNullOrEmpty(pluginName); config.PluginTypes = Types.Select(x => Enum.TryParse(x, true, out var result) ? result : (PluginType?)null).OfType().ToHashSet(); + if (AuthReferenceId is not null && Enum.TryParse(AuthType, out var authType)) + { + config.PluginAuthInformation = new PluginAuthConfiguration(AuthReferenceId) + { + AuthType = authType, + }; + } UpdateGenerationConfigurationFromBase(config, pluginName, requests); } } diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfigurationComparer.cs b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfigurationComparer.cs index 44f1366325..b61200c710 100644 --- a/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfigurationComparer.cs +++ b/src/Kiota.Builder/WorkspaceManagement/ApiPluginConfigurationComparer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Kiota.Builder.Lock; @@ -13,9 +14,20 @@ public class ApiPluginConfigurationComparer : BaseApiConsumerConfigurationCompar /// public override int GetHashCode([DisallowNull] ApiPluginConfiguration obj) { - if (obj == null) return 0; + var hash = new HashCode(); + if (obj == null) return hash.ToHashCode(); + if (obj.AuthType is { } authType) + { + hash.Add(authType, StringComparer.OrdinalIgnoreCase); + } + hash.Add(0); + if (obj.AuthReferenceId is { } referenceId) + { + hash.Add(referenceId, StringComparer.OrdinalIgnoreCase); + } return _stringIEnumerableDeepComparer.GetHashCode(obj.Types?.Order(StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty()) * 11 + + hash.ToHashCode() + base.GetHashCode(obj); } } diff --git a/tests/Kiota.Builder.Tests/Plugins/PluginAuthConfigurationTests.cs b/tests/Kiota.Builder.Tests/Plugins/PluginAuthConfigurationTests.cs new file mode 100644 index 0000000000..2c6d9b50e1 --- /dev/null +++ b/tests/Kiota.Builder.Tests/Plugins/PluginAuthConfigurationTests.cs @@ -0,0 +1,47 @@ +using System; +using Kiota.Builder.Configuration; +using Kiota.Builder.Plugins; +using Xunit; + +namespace Kiota.Builder.Tests.Plugins; + +public class PluginAuthConfigurationTests +{ + [Fact] + public void ThrowsExceptionIfReferenceIdIsNullOrEmpty() + { + Assert.Throws(() => + { + _ = new PluginAuthConfiguration(null); + }); + Assert.Throws(() => + { + _ = new PluginAuthConfiguration(string.Empty); + }); + } + + [Fact] + public void ThrowsExceptionWhenToPluginManifestAuthEncountersUnsupportedAuthType() + { + var auth = new PluginAuthConfiguration("reference") + { + AuthType = (PluginAuthType)10 + }; + Assert.Throws(() => + { + auth.ToPluginManifestAuth(); + }); + } + + [Fact] + public void AddCoverageOnUnsupportedException() + { + _ = new UnsupportedSecuritySchemeException(); + _ = new UnsupportedSecuritySchemeException("msg"); + _ = new UnsupportedSecuritySchemeException("msg", new Exception()); + var a = new UnsupportedSecuritySchemeException(["t0"], "msg"); + Assert.NotEmpty(a.SupportedTypes); + var b = new UnsupportedSecuritySchemeException(["t0"], "msg", new Exception()); + Assert.NotEmpty(b.SupportedTypes); + } +} diff --git a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs index e63d8394cb..6af6f920cf 100644 --- a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs +++ b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs @@ -18,6 +18,7 @@ namespace Kiota.Builder.Tests.Plugins; public sealed class PluginsGenerationServiceTests : IDisposable { private readonly HttpClient _httpClient = new(); + [Fact] public void Defensive() { @@ -230,4 +231,284 @@ public async Task GeneratesManifestAndCleansUpInputDescriptionAsync() Assert.NotEmpty(resultDocument.Paths["/test/{id}"].Operations[OperationType.Get].Responses["200"].Description);// response description string is not empty Assert.Single(resultDocument.Paths["/test/{id}"].Operations[OperationType.Get].Extensions); // 1 supported extension still present in operation } + + public static TheoryData>> + SecurityInformationSuccess() + { + return new TheoryData>> + { + // security scheme in operation object + { + "{securitySchemes: {apiKey0: {type: apiKey, name: x-api-key, in: header }}}", + string.Empty, "security: [apiKey0: []]", null, resultingManifest => + { + Assert.NotNull(resultingManifest.Document); + Assert.Empty(resultingManifest.Problems); + Assert.NotEmpty(resultingManifest.Document.Runtimes); + var auth0 = resultingManifest.Document.Runtimes[0].Auth; + Assert.IsType(auth0); + Assert.Equal(AuthType.ApiKeyPluginVault, auth0?.Type); + Assert.Equal("{apiKey0_REGISTRATION_ID}", ((ApiKeyPluginVault)auth0!).ReferenceId); + } + }, + // security scheme in root object + // TODO: Revisit when https://github.com/microsoft/OpenAPI.NET/issues/1797 is fixed + // { + // "{securitySchemes: {apiKey0: {type: apiKey, name: x-api-key, in: header }}}", + // "security: [apiKey0: []]", string.Empty, null, resultingManifest => + // { + // Assert.NotNull(resultingManifest.Document); + // Assert.Empty(resultingManifest.Problems); + // Assert.NotEmpty(resultingManifest.Document.Runtimes); + // var auth0 = resultingManifest.Document.Runtimes[0].Auth; + // Assert.IsType(auth0); + // Assert.Equal(AuthType.ApiKeyPluginVault, auth0?.Type); + // Assert.Equal("{apiKey0_REGISTRATION_ID}", ((ApiKeyPluginVault)auth0!).ReferenceId); + // } + // }, + // auth provided in config overrides openapi file auth + { + "{securitySchemes: {apiKey0: {type: apiKey, name: x-api-key, in: header }}}", + string.Empty, "security: [apiKey0: []]", new PluginAuthConfiguration("different_ref_id") {AuthType = PluginAuthType.OAuthPluginVault}, resultingManifest => + { + Assert.NotNull(resultingManifest.Document); + Assert.Empty(resultingManifest.Problems); + Assert.NotEmpty(resultingManifest.Document.Runtimes); + var auth0 = resultingManifest.Document.Runtimes[0].Auth; + Assert.IsType(auth0); + Assert.Equal(AuthType.OAuthPluginVault, auth0?.Type); + Assert.Equal("different_ref_id", ((OAuthPluginVault)auth0!).ReferenceId); + } + }, + // auth provided in config applies when no openapi file auth + { + "{}", + string.Empty, string.Empty, + new PluginAuthConfiguration("different_ref_id") {AuthType = PluginAuthType.OAuthPluginVault}, + resultingManifest => + { + Assert.NotNull(resultingManifest.Document); + Assert.Empty(resultingManifest.Problems); + Assert.NotEmpty(resultingManifest.Document.Runtimes); + var auth0 = resultingManifest.Document.Runtimes[0].Auth; + Assert.IsType(auth0); + Assert.Equal(AuthType.OAuthPluginVault, auth0?.Type); + Assert.Equal("different_ref_id", ((OAuthPluginVault)auth0!).ReferenceId); + } + }, + // http bearer auth + { + "{securitySchemes: {httpBearer0: {type: http, scheme: bearer}}}", + string.Empty, "security: [httpBearer0: []]", null, resultingManifest => + { + Assert.NotNull(resultingManifest.Document); + Assert.Empty(resultingManifest.Problems); + Assert.NotEmpty(resultingManifest.Document.Runtimes); + var auth0 = resultingManifest.Document.Runtimes[0].Auth; + Assert.IsType(auth0); + Assert.Equal(AuthType.ApiKeyPluginVault, auth0?.Type); + Assert.Equal("{httpBearer0_REGISTRATION_ID}", ((ApiKeyPluginVault)auth0!).ReferenceId); + } + }, + // openid connect auth + { + "{securitySchemes: {openIdConnect0: {type: openIdConnect, openIdConnectUrl: 'http://auth.com'}}}", + string.Empty, "security: [openIdConnect0: []]", null, resultingManifest => + { + Assert.NotNull(resultingManifest.Document); + Assert.Empty(resultingManifest.Problems); + Assert.NotEmpty(resultingManifest.Document.Runtimes); + var auth0 = resultingManifest.Document.Runtimes[0].Auth; + Assert.IsType(auth0); + Assert.Equal(AuthType.ApiKeyPluginVault, auth0?.Type); + Assert.Equal("{openIdConnect0_REGISTRATION_ID}", ((ApiKeyPluginVault)auth0!).ReferenceId); + } + }, + // oauth2 + { + "{securitySchemes: {oauth2_0: {type: oauth2, flows: {}}}}", + string.Empty, "security: [oauth2_0: []]", null, resultingManifest => + { + Assert.NotNull(resultingManifest.Document); + Assert.Empty(resultingManifest.Problems); + Assert.NotEmpty(resultingManifest.Document.Runtimes); + var auth0 = resultingManifest.Document.Runtimes[0].Auth; + Assert.IsType(auth0); + Assert.Equal(AuthType.OAuthPluginVault, auth0?.Type); + Assert.Equal("{oauth2_0_CONFIGURATION_ID}", ((OAuthPluginVault)auth0!).ReferenceId); + } + }, + // should be anonymous + { + "{}", string.Empty, "security: [invalid: []]", null, resultingManifest => + { + Assert.NotNull(resultingManifest.Document); + Assert.Empty(resultingManifest.Problems); + Assert.NotEmpty(resultingManifest.Document.Runtimes); + var auth0 = resultingManifest.Document.Runtimes[0].Auth; + Assert.IsType(auth0); + } + } + }; + } + + [Theory] + [MemberData(nameof(SecurityInformationSuccess))] + public async Task GeneratesManifestWithAuthAsync(string securitySchemesComponent, string rootSecurity, + string operationSecurity, PluginAuthConfiguration pluginAuthConfiguration, Action> assertions) + { + var apiDescription = $""" + openapi: 3.0.0 + info: + title: test + version: "1.0" + paths: + /test: + get: + description: description for test path + responses: + '200': + description: test + {operationSecurity} + {rootSecurity} + components: {securitySchemesComponent} + """; + var workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var simpleDescriptionPath = Path.Combine(workingDirectory) + "description.yaml"; + await File.WriteAllTextAsync(simpleDescriptionPath, apiDescription); + var mockLogger = new Mock>(); + var openApiDocumentDs = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object); + var outputDirectory = Path.Combine(workingDirectory, "output"); + var generationConfiguration = new GenerationConfiguration + { + OutputPath = outputDirectory, + OpenAPIFilePath = "openapiPath", + PluginTypes = [PluginType.APIPlugin], + ClientClassName = "client", + ApiRootUrl = "http://localhost/", //Kiota builder would set this for us + PluginAuthInformation = pluginAuthConfiguration, + }; + var (openApiDocumentStream, _) = + await openApiDocumentDs.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false); + var openApiDocument = + await openApiDocumentDs.GetDocumentFromStreamAsync(openApiDocumentStream, generationConfiguration); + Assert.NotNull(openApiDocument); + KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument); + var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel); + + var pluginsGenerationService = + new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory); + await pluginsGenerationService.GenerateManifestAsync(); + + Assert.True(File.Exists(Path.Combine(outputDirectory, ManifestFileName))); + Assert.True(File.Exists(Path.Combine(outputDirectory, OpenApiFileName))); + + // Validate the v2 plugin + var manifestContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, ManifestFileName)); + using var jsonDocument = JsonDocument.Parse(manifestContent); + var resultingManifest = PluginManifestDocument.Load(jsonDocument.RootElement); + + assertions(resultingManifest); + // Cleanup + try + { + Directory.Delete(outputDirectory); + } + catch (Exception) + { + // ignored + } + } + + public static TheoryData, Task>> + SecurityInformationFail() + { + return new TheoryData, Task>> + { + // multiple security schemes in operation object + { + "{securitySchemes: {apiKey0: {type: apiKey, name: x-api-key0, in: header}, apiKey1: {type: apiKey, name: x-api-key1, in: header}}}", + string.Empty, "security: [apiKey0: [], apiKey1: []]", null, async (action) => + { + await Assert.ThrowsAsync(async () => + { + await action(); + }); + } + }, + // Unsupported security scheme (http basic) + { + "{securitySchemes: {httpBasic0: {type: http, scheme: basic}}}", + string.Empty, "security: [httpBasic0: []]", null, async (action) => + { + await Assert.ThrowsAsync(async () => + { + await action(); + }); + } + }, + }; + } + + [Theory] + [MemberData(nameof(SecurityInformationFail))] + public async Task FailsToGeneratesManifestWithInvalidAuthAsync(string securitySchemesComponent, string rootSecurity, + string operationSecurity, PluginAuthConfiguration pluginAuthConfiguration, Func, Task> assertions) + { + var apiDescription = $""" + openapi: 3.0.0 + info: + title: test + version: "1.0" + paths: + /test: + get: + description: description for test path + responses: + '200': + description: test + {operationSecurity} + {rootSecurity} + components: {securitySchemesComponent} + """; + var workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var simpleDescriptionPath = Path.Combine(workingDirectory) + "description.yaml"; + await File.WriteAllTextAsync(simpleDescriptionPath, apiDescription); + var mockLogger = new Mock>(); + var openApiDocumentDs = new OpenApiDocumentDownloadService(_httpClient, mockLogger.Object); + var outputDirectory = Path.Combine(workingDirectory, "output"); + var generationConfiguration = new GenerationConfiguration + { + OutputPath = outputDirectory, + OpenAPIFilePath = "openapiPath", + PluginTypes = [PluginType.APIPlugin], + ClientClassName = "client", + ApiRootUrl = "http://localhost/", //Kiota builder would set this for us + PluginAuthInformation = pluginAuthConfiguration, + }; + var (openApiDocumentStream, _) = + await openApiDocumentDs.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false); + var openApiDocument = + await openApiDocumentDs.GetDocumentFromStreamAsync(openApiDocumentStream, generationConfiguration); + Assert.NotNull(openApiDocument); + KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument); + var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel); + + var pluginsGenerationService = + new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory); + + await assertions(async () => + { + await pluginsGenerationService.GenerateManifestAsync(); + }); + // cleanup + try + { + Directory.Delete(outputDirectory); + } + catch (Exception) + { + // ignored + } + } }