diff --git a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs index 8e95375d1b..0bb711394c 100644 --- a/src/Kiota.Builder/Configuration/GenerationConfiguration.cs +++ b/src/Kiota.Builder/Configuration/GenerationConfiguration.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text.Json.Nodes; +using Kiota.Builder.Extensions; using Kiota.Builder.Lock; using Microsoft.OpenApi.ApiManifest; @@ -113,6 +114,10 @@ public StructuredMimeTypesCollection StructuredMimeTypes }; public HashSet IncludePatterns { get; set; } = new(0, StringComparer.OrdinalIgnoreCase); public HashSet ExcludePatterns { get; set; } = new(0, StringComparer.OrdinalIgnoreCase); + /// + /// The overrides loaded from the api manifest when refreshing a client, as opposed to the user provided ones. + /// + public HashSet PatternsOverride { get; set; } = new(0, StringComparer.OrdinalIgnoreCase); public bool ClearCache { get; set; @@ -144,6 +149,7 @@ public object Clone() MaxDegreeOfParallelism = MaxDegreeOfParallelism, SkipGeneration = SkipGeneration, Operation = Operation, + PatternsOverride = new(PatternsOverride ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase), }; } private static readonly StringIEnumerableDeepComparer comparer = new(); @@ -175,7 +181,7 @@ public ApiDependency ToApiDependency(string configurationHash, Dictionary x.Value.Select(y => new RequestInfo { Method = y.ToUpperInvariant(), UriTemplate = x.Key })).ToList(), + Requests = templatesWithOperations.SelectMany(static x => x.Value.Select(y => new RequestInfo { Method = y.ToUpperInvariant(), UriTemplate = x.Key.DeSanitizeUrlTemplateParameter() })).ToList(), }; return dependency; } diff --git a/src/Kiota.Builder/Extensions/OpenApiUrlTreeNodeExtensions.cs b/src/Kiota.Builder/Extensions/OpenApiUrlTreeNodeExtensions.cs index 00c818a3e8..ea6116804f 100644 --- a/src/Kiota.Builder/Extensions/OpenApiUrlTreeNodeExtensions.cs +++ b/src/Kiota.Builder/Extensions/OpenApiUrlTreeNodeExtensions.cs @@ -266,6 +266,13 @@ public static string SanitizeParameterNameForUrlTemplate(this string original) .Replace(".", "%2E", StringComparison.OrdinalIgnoreCase) .Replace("~", "%7E", StringComparison.OrdinalIgnoreCase);// - . ~ are invalid uri template character but don't get encoded by Uri.EscapeDataString } + public static string DeSanitizeUrlTemplateParameter(this string original) + { + if (string.IsNullOrEmpty(original)) return original; + return Uri.UnescapeDataString(original.Replace("%2D", "-", StringComparison.OrdinalIgnoreCase) + .Replace("%2E", ".", StringComparison.OrdinalIgnoreCase) + .Replace("%7E", "~", StringComparison.OrdinalIgnoreCase)); + } [GeneratedRegex(@"%[0-9A-F]{2}", RegexOptions.Singleline, 500)] private static partial Regex removePctEncodedCharacters(); public static string SanitizeParameterNameForCodeSymbols(this string original, string replaceEncodedCharactersWith = "") diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 42b40b2d03..b9ae9bf50d 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -321,6 +321,11 @@ internal void FilterPathsByPatterns(OpenApiDocument doc) { var includePatterns = GetFilterPatternsFromConfiguration(config.IncludePatterns); var excludePatterns = GetFilterPatternsFromConfiguration(config.ExcludePatterns); + if (config.PatternsOverride.Count != 0) + { // loading the patterns from the manifest as we don't want to take the user input one and have new operation creep in from the description being updated since last generation + includePatterns = GetFilterPatternsFromConfiguration(config.PatternsOverride); + excludePatterns = []; + } if (includePatterns.Count == 0 && excludePatterns.Count == 0) return; var nonOperationIncludePatterns = includePatterns.Where(static x => x.Value.Count == 0).Select(static x => x.Key).ToList(); diff --git a/src/Kiota.Builder/OpenApiDocumentDownloadService.cs b/src/Kiota.Builder/OpenApiDocumentDownloadService.cs index 2ff8c3d180..17d06e1a6e 100644 --- a/src/Kiota.Builder/OpenApiDocumentDownloadService.cs +++ b/src/Kiota.Builder/OpenApiDocumentDownloadService.cs @@ -48,7 +48,7 @@ public OpenApiDocumentDownloadService(HttpClient httpClient, ILogger logger) if (useKiotaConfig && config.Operation is ClientOperation.Edit or ClientOperation.Add && workspaceManagementService is not null && - await workspaceManagementService.GetDescriptionCopyAsync(config.ClientClassName, inputPath, cancellationToken).ConfigureAwait(false) is { } descriptionStream) + await workspaceManagementService.GetDescriptionCopyAsync(config.ClientClassName, inputPath, config.CleanOutput, cancellationToken).ConfigureAwait(false) is { } descriptionStream) { Logger.LogInformation("loaded description from the workspace copy"); input = descriptionStream; diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs index 46a3f98167..d47759564f 100644 --- a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs +++ b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs @@ -4,6 +4,7 @@ using System.Linq; using Kiota.Builder.Configuration; using Kiota.Builder.Lock; +using Microsoft.OpenApi.ApiManifest; namespace Kiota.Builder.WorkspaceManagement; @@ -92,34 +93,13 @@ public ApiClientConfiguration(GenerationConfiguration config) OutputPath = config.OutputPath; } /// - /// Initializes a new instance of the class from an existing to enable migrations. - /// - /// The kiota lock to migrate. - /// The relative output path to output folder from the configuration file. - public ApiClientConfiguration(KiotaLock kiotaLock, string relativeOutputPath) - { - ArgumentNullException.ThrowIfNull(kiotaLock); - ArgumentNullException.ThrowIfNull(relativeOutputPath); - Language = kiotaLock.Language; - ClientNamespaceName = kiotaLock.ClientNamespaceName; - UsesBackingStore = kiotaLock.UsesBackingStore; - ExcludeBackwardCompatible = kiotaLock.ExcludeBackwardCompatible; - IncludeAdditionalData = kiotaLock.IncludeAdditionalData; - StructuredMimeTypes = kiotaLock.StructuredMimeTypes.ToList(); - IncludePatterns = kiotaLock.IncludePatterns; - ExcludePatterns = kiotaLock.ExcludePatterns; - DescriptionLocation = kiotaLock.DescriptionLocation; - DisabledValidationRules = kiotaLock.DisabledValidationRules; - OutputPath = relativeOutputPath; - } - /// /// Updates the passed configuration with the values from the config file. /// /// Generation configuration to update. /// Client name serving as class name. - public void UpdateGenerationConfigurationFromApiClientConfiguration(GenerationConfiguration config, string clientName) + /// The requests to use when updating an existing client. + public void UpdateGenerationConfigurationFromApiClientConfiguration(GenerationConfiguration config, string clientName, IList? requests = default) { - //TODO lock the api manifest as well to have accurate path resolution ArgumentNullException.ThrowIfNull(config); ArgumentException.ThrowIfNullOrEmpty(clientName); config.ClientNamespaceName = ClientNamespaceName; @@ -137,6 +117,12 @@ public void UpdateGenerationConfigurationFromApiClientConfiguration(GenerationCo 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(); + } } public object Clone() diff --git a/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs b/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs index 7874db312c..9a68d50f41 100644 --- a/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs +++ b/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs @@ -121,9 +121,9 @@ public async Task ShouldGenerateAsync(GenerationConfiguration inputConfig, } } - public async Task GetDescriptionCopyAsync(string clientName, string inputPath, CancellationToken cancellationToken = default) + public async Task GetDescriptionCopyAsync(string clientName, string inputPath, bool cleanOutput, CancellationToken cancellationToken = default) { - if (!UseKiotaConfig) + if (!UseKiotaConfig || cleanOutput) return null; return await descriptionStorageService.GetDescriptionAsync(clientName, new Uri(inputPath).GetFileExtension(), cancellationToken).ConfigureAwait(false); } diff --git a/src/kiota/Handlers/BaseKiotaCommandHandler.cs b/src/kiota/Handlers/BaseKiotaCommandHandler.cs index 32b0baef3a..f22034eecf 100644 --- a/src/kiota/Handlers/BaseKiotaCommandHandler.cs +++ b/src/kiota/Handlers/BaseKiotaCommandHandler.cs @@ -304,10 +304,10 @@ protected void DisplayInstallHint(LanguageInformation languageInformation, List< languageDependencies.Select(x => " " + string.Format(languageInformation.DependencyInstallCommand, x.Name, x.Version))).ToArray()); } } - protected void DisplayCleanHint(string commandName) + protected void DisplayCleanHint(string commandName, string argumentName = "--clean-output") { - DisplayHint("Hint: to force the generation to overwrite an existing client pass the --clean-output switch.", - $"Example: kiota {commandName} --clean-output"); + DisplayHint($"Hint: to force the generation to overwrite an existing client pass the {argumentName} switch.", + $"Example: kiota {commandName} {argumentName}"); } protected void DisplayInfoAdvancedHint() { diff --git a/src/kiota/Handlers/Client/GenerateHandler.cs b/src/kiota/Handlers/Client/GenerateHandler.cs new file mode 100644 index 0000000000..bf8d13a471 --- /dev/null +++ b/src/kiota/Handlers/Client/GenerateHandler.cs @@ -0,0 +1,92 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Kiota.Builder; +using Kiota.Builder.Configuration; +using Kiota.Builder.WorkspaceManagement; +using Microsoft.Extensions.Logging; + +namespace kiota.Handlers.Client; + +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 + .Clients + .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.UpdateGenerationConfigurationFromApiClientConfiguration(generationConfiguration, clientEntry.Key, requests); + generationConfiguration.ClearCache = refresh; + generationConfiguration.CleanOutput = refresh; + var builder = new KiotaBuilder(logger, generationConfiguration, httpClient, true); + var result = await builder.GenerateClientAsync(cancellationToken).ConfigureAwait(false); + if (result) + { + DisplaySuccess($"Update of {clientEntry.Key} client completed"); + var manifestPath = $"{GetAbsolutePath(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 System.NotImplementedException(); + } +} diff --git a/src/kiota/Handlers/KiotaUpdateCommandHandler.cs b/src/kiota/Handlers/KiotaUpdateCommandHandler.cs index d21b5f71ac..391d2d1f47 100644 --- a/src/kiota/Handlers/KiotaUpdateCommandHandler.cs +++ b/src/kiota/Handlers/KiotaUpdateCommandHandler.cs @@ -70,7 +70,7 @@ public override async Task InvokeAsync(InvocationContext context) DisplaySuccess($"Update of {locks.Length} clients completed successfully"); foreach (var configuration in configurations) DisplayInfoHint(configuration.Language, configuration.OpenAPIFilePath, string.Empty); - if (results.Any(x => x)) + if (Array.Exists(results, static x => x)) DisplayCleanHint("update"); return 0; } diff --git a/src/kiota/KiotaClientCommands.cs b/src/kiota/KiotaClientCommands.cs index 07a3f998e4..0511a3bec9 100644 --- a/src/kiota/KiotaClientCommands.cs +++ b/src/kiota/KiotaClientCommands.cs @@ -110,8 +110,27 @@ public static Command GetEditCommand() } public static Command GetGenerateCommand() { - var command = new Command("generate", "Generates one or all clients from the Kiota configuration"); - //TODO map the handler + var clientNameOption = GetClientNameOption(false); + var logLevelOption = KiotaHost.GetLogLevelOption(); + var refreshOption = GetRefreshOption(); + var command = new Command("generate", "Generates one or all clients from the Kiota configuration") + { + clientNameOption, + logLevelOption, + refreshOption, + }; + command.Handler = new GenerateHandler + { + ClassOption = clientNameOption, + LogLevelOption = logLevelOption, + RefreshOption = refreshOption, + }; return command; } + private static Option GetRefreshOption() + { + var refresh = new Option("--refresh", "Refreshes the client OpenAPI description before generating the client"); + refresh.AddAlias("--r"); + return refresh; + } } diff --git a/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationTests.cs b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationTests.cs index eb13ac8f08..130b407c8b 100644 --- a/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationTests.cs +++ b/tests/Kiota.Builder.Tests/WorkspaceManagement/ApiClientConfigurationTests.cs @@ -1,6 +1,7 @@ using System; using Kiota.Builder.Configuration; using Kiota.Builder.WorkspaceManagement; +using Microsoft.OpenApi.ApiManifest; using Xunit; namespace Kiota.Builder.Tests.WorkspaceManagement; @@ -91,7 +92,18 @@ public void UpdatesGenerationConfigurationFromApiClientConfiguration() UsesBackingStore = true, }; var generationConfiguration = new GenerationConfiguration(); - clientConfiguration.UpdateGenerationConfigurationFromApiClientConfiguration(generationConfiguration, "client"); + clientConfiguration.UpdateGenerationConfigurationFromApiClientConfiguration(generationConfiguration, "client", [ + new RequestInfo + { + Method = "GET", + UriTemplate = "path/bar", + }, + new RequestInfo + { + Method = "PATH", + UriTemplate = "path/baz", + }, + ]); Assert.Equal(clientConfiguration.ClientNamespaceName, generationConfiguration.ClientNamespaceName); Assert.Equal(GenerationLanguage.CSharp, generationConfiguration.Language); Assert.Equal(clientConfiguration.DescriptionLocation, generationConfiguration.OpenAPIFilePath); @@ -104,6 +116,7 @@ public void UpdatesGenerationConfigurationFromApiClientConfiguration() Assert.Equal(clientConfiguration.UsesBackingStore, generationConfiguration.UsesBackingStore); Assert.Empty(generationConfiguration.Serializers); Assert.Empty(generationConfiguration.Deserializers); + Assert.Equal(2, generationConfiguration.PatternsOverride.Count); } } diff --git a/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs b/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs index f02053143d..925e5b0e65 100644 --- a/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs +++ b/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Kiota.Builder.Configuration; using Kiota.Builder.Lock; +using Kiota.Builder.Tests.Manifest; using Kiota.Builder.WorkspaceManagement; using Microsoft.Extensions.Logging; using Moq; @@ -33,10 +34,12 @@ public async Task IsClientPresentReturnsFalseOnNoClient(bool usesConfig) var result = await service.IsClientPresent("clientName"); Assert.False(result); } - [InlineData(true)] - [InlineData(false)] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] [Theory] - public async Task ShouldGenerateReturnsTrue(bool usesConfig) + public async Task ShouldGenerateReturnsTrue(bool usesConfig, bool cleanOutput) { var mockLogger = Mock.Of(); Directory.CreateDirectory(tempPath); @@ -46,6 +49,7 @@ public async Task ShouldGenerateReturnsTrue(bool usesConfig) ClientClassName = "clientName", OutputPath = tempPath, OpenAPIFilePath = Path.Combine(tempPath, "openapi.yaml"), + CleanOutput = cleanOutput, }; var result = await service.ShouldGenerateAsync(configuration, "foo"); Assert.True(result); @@ -172,6 +176,48 @@ await File.WriteAllTextAsync(descriptionPath, @$"openapi: 3.0.1 Assert.True(File.Exists(Path.Combine(tempPath, WorkspaceConfigurationStorageService.ManifestFileName))); Assert.True(File.Exists(Path.Combine(tempPath, DescriptionStorageService.DescriptionsSubDirectoryRelativePath, "clientName.yml"))); } + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + [Theory] + public async Task GetsADescription(bool usesConfig, bool cleanOutput) + { + var mockLogger = Mock.Of(); + Directory.CreateDirectory(tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, usesConfig, tempPath); + var descriptionPath = Path.Combine(tempPath, $"{DescriptionStorageService.DescriptionsSubDirectoryRelativePath}/clientName.yml"); + var outputPath = Path.Combine(tempPath, "client"); + Directory.CreateDirectory(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(descriptionPath)); + await File.WriteAllTextAsync(descriptionPath, @$"openapi: 3.0.1 +info: + title: OData Service for namespace microsoft.graph + description: This OData service is located at https://graph.microsoft.com/v1.0 + version: 1.0.1 +servers: + - url: https://localhost:443 +paths: + /enumeration: + get: + responses: + '200': + content: + application/json: + schema: + type: object + properties: + bar: + type: object + properties: + foo: + type: string"); + var descriptionCopy = await service.GetDescriptionCopyAsync("clientName", descriptionPath, cleanOutput); + if (!usesConfig || cleanOutput) + Assert.Null(descriptionCopy); + else + Assert.NotNull(descriptionCopy); + } public void Dispose() {