diff --git a/.vscode/launch.json b/.vscode/launch.json index 36465ec859..e5404b0746 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -265,6 +265,20 @@ "console": "internalConsole", "stopAtEntry": false }, + { + "name": "Launch Migrate", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/kiota/bin/Debug/net8.0/kiota.dll", + "args": ["config", "migrate"], + "cwd": "${workspaceFolder}/samples/msgraph-mail/dotnet", + "console": "internalConsole", + "stopAtEntry": false, + "env": { + "KIOTA_CONFIG_PREVIEW": "true" + } + }, { "name": "Launch Login (github - device)", "type": "coreclr", diff --git a/specs/cli/config-migrate.md b/specs/cli/config-migrate.md index cfd1aca484..ddab8f8d2a 100644 --- a/specs/cli/config-migrate.md +++ b/specs/cli/config-migrate.md @@ -2,14 +2,16 @@ This command is valuable in cases where a code base was created with Kiota v1.0 and needs to be migrated to the latest version of Kiota. The `kiota config migrate` command will identify and locate the closest `kiota-config.json` file available. If a file can't be found, it would initialize a new `kiota-config.json` file. Then, it would identify all `kiota-lock.json` files that are within this folder structure and add each of them to the `kiota-config.json` file. Adding the clients to the `kiota-config.json` file would not trigger the generation as it only affects the `kiota-config.json` file. The `kiota client generate` command would need to be executed to generate the code for the clients. +The API manifest won't contain any request after the migration since it could lead to misalignments between the generated client and the reported requests if the description has changed between the initial generation of the client and the migration. To get the requests populated, the user will need to use the generate command. + In the case where conflicting API client names would be migrated, the command will error out and invite the user to re-run the command providing more context for the `--client-name` parameter. ## Parameters | Parameters | Required | Example | Description | Telemetry | | -- | -- | -- | -- | -- | -| `--lock-location` | No | ./output/pythonClient/kiota-lock.json | Location of the `kiota-lock.json` file. If not specified, all `kiota-lock.json` files within in the current directory tree will be used. | Yes, without its value | -| `--client-name \| --cn` | No | graphDelegated | Used with `--lock-location`, it would allow to specify a name for the API client. Else, name is auto-generated as a concatenation of the `language` and `clientClassName`. | Yes, without its value | +| `--lock-directory \| --ld` | No | ./output/pythonClient/ | Relative path to the directory containing the `kiota-lock.json` file. If not specified, all `kiota-lock.json` files within in the current directory tree will be used. | Yes, without its value | +| `--client-name \| --cn` | No | graphDelegated | Used with `--lock-directory`, it would allow to specify a name for the API client. Else, name is auto-generated as a concatenation of the `language` and `clientClassName`. | Yes, without its value | ## Using `kiota config migrate` @@ -80,64 +82,14 @@ _The resulting `apimanifest.json` file will look like this:_ "apiDescriptionUrl": "https://aka.ms/graph/v1.0/openapi.yaml", "apiDeploymentBaseUrl": "https://graph.microsoft.com", "apiDescriptionVersion": "v1.0", - "requests": [ - { - "method": "GET", - "uriTemplate": "/users" - }, - { - "method": "POST", - "uriTemplate": "/users" - }, - { - "method": "GET", - "uriTemplate": "/users/$count" - }, - { - "method": "GET", - "uriTemplate": "/users/{user-id}" - }, - { - "method": "PATCH", - "uriTemplate": "/users/{user-id}" - }, - { - "method": "DELETE", - "uriTemplate": "/users/{user-id}" - } - ] + "requests": [] }, "pythonGraphServiceClient": { "x-ms-kiotaHash": "9EDF8506CB74FE44...", "apiDescriptionUrl": "https://aka.ms/graph/v1.0/openapi.yaml", "apiDeploymentBaseUrl": "https://graph.microsoft.com", "apiDescriptionVersion": "v1.0", - "requests": [ - { - "method": "GET", - "uriTemplate": "/users" - }, - { - "method": "POST", - "uriTemplate": "/users" - }, - { - "method": "GET", - "uriTemplate": "/users/$count" - }, - { - "method": "GET", - "uriTemplate": "/users/{user-id}" - }, - { - "method": "PATCH", - "uriTemplate": "/users/{user-id}" - }, - { - "method": "DELETE", - "uriTemplate": "/users/{user-id}" - } - ] + "requests": [] } } } @@ -181,7 +133,7 @@ Assuming the following folder structure: ``` ```bash -kiota config migrate --lock-location ./generated/graph/csharp/kiota-lock.json --client-name GraphClient +kiota config migrate --lock-directory ./generated/graph/csharp --client-name GraphClient ``` _The resulting `kiota-config.json` file will look like this:_ diff --git a/src/Kiota.Builder/Extensions/OpenApiDocumentExtensions.cs b/src/Kiota.Builder/Extensions/OpenApiDocumentExtensions.cs index e0abc3cb94..dc46ca6807 100644 --- a/src/Kiota.Builder/Extensions/OpenApiDocumentExtensions.cs +++ b/src/Kiota.Builder/Extensions/OpenApiDocumentExtensions.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Linq; using System.Threading.Tasks; +using Kiota.Builder.EqualityComparers; using Microsoft.OpenApi.Models; namespace Kiota.Builder.Extensions; @@ -26,4 +27,24 @@ internal static void InitializeInheritanceIndex(this OpenApiDocument openApiDocu }); } } + internal static string? GetAPIRootUrl(this OpenApiDocument openApiDocument, string openAPIFilePath) + { + ArgumentNullException.ThrowIfNull(openApiDocument); + var candidateUrl = openApiDocument.Servers + .GroupBy(static x => x, new OpenApiServerComparer()) //group by protocol relative urls + .FirstOrDefault() + ?.OrderByDescending(static x => x.Url, StringComparer.OrdinalIgnoreCase) // prefer https over http + ?.FirstOrDefault() + ?.Url; + if (string.IsNullOrEmpty(candidateUrl)) + return null; + else if (!candidateUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) && + openAPIFilePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) && + Uri.TryCreate(openAPIFilePath, new(), out var filePathUri) && + Uri.TryCreate(filePathUri, candidateUrl, out var candidateUri)) + { + candidateUrl = candidateUri.ToString(); + } + return candidateUrl.TrimEnd(KiotaBuilder.ForwardSlash); + } } diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 200458a1e4..42b40b2d03 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -8,11 +8,9 @@ using System.Linq; using System.Net.Http; using System.Runtime.CompilerServices; -using System.Security; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using AsyncKeyedLock; using DotNet.Globbing; using Kiota.Builder.Caching; using Kiota.Builder.CodeDOM; @@ -25,8 +23,6 @@ using Kiota.Builder.Manifest; using Kiota.Builder.OpenApiExtensions; using Kiota.Builder.Refiners; -using Kiota.Builder.SearchProviders.APIsGuru; -using Kiota.Builder.Validation; using Kiota.Builder.WorkspaceManagement; using Kiota.Builder.Writers; using Microsoft.Extensions.Logging; @@ -34,9 +30,7 @@ using Microsoft.OpenApi.ApiManifest; using Microsoft.OpenApi.MicrosoftExtensions; using Microsoft.OpenApi.Models; -using Microsoft.OpenApi.Readers; using Microsoft.OpenApi.Services; -using Microsoft.OpenApi.Validations; using HttpMethod = Kiota.Builder.CodeDOM.HttpMethod; [assembly: InternalsVisibleTo("Kiota.Builder.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100957cb48387b2a5f54f5ce39255f18f26d32a39990db27cf48737afc6bc62759ba996b8a2bfb675d4e39f3d06ecb55a178b1b4031dcb2a767e29977d88cce864a0d16bfc1b3bebb0edf9fe285f10fffc0a85f93d664fa05af07faa3aad2e545182dbf787e3fd32b56aca95df1a3c4e75dec164a3f1a4c653d971b01ffc39eb3c4")] @@ -64,9 +58,11 @@ public KiotaBuilder(ILogger logger, GenerationConfiguration config MaxDegreeOfParallelism = config.MaxDegreeOfParallelism, }; var workingDirectory = Directory.GetCurrentDirectory(); - workspaceManagementService = new WorkspaceManagementService(logger, useKiotaConfig, workingDirectory); + workspaceManagementService = new WorkspaceManagementService(logger, client, useKiotaConfig, workingDirectory); this.useKiotaConfig = useKiotaConfig; + openApiDocumentDownloadService = new OpenApiDocumentDownloadService(client, logger); } + private readonly OpenApiDocumentDownloadService openApiDocumentDownloadService; private readonly bool useKiotaConfig; private async Task CleanOutputDirectory(CancellationToken cancellationToken) { @@ -359,33 +355,10 @@ internal void FilterPathsByPatterns(OpenApiDocument doc) } internal void SetApiRootUrl() { - if (openApiDocument == null) return; - var candidateUrl = openApiDocument.Servers - .GroupBy(static x => x, new OpenApiServerComparer()) //group by protocol relative urls - .FirstOrDefault() - ?.OrderByDescending(static x => x?.Url, StringComparer.OrdinalIgnoreCase) // prefer https over http - ?.FirstOrDefault() - ?.Url; - if (string.IsNullOrEmpty(candidateUrl)) - { + if (openApiDocument is not null && openApiDocument.GetAPIRootUrl(config.OpenAPIFilePath) is string candidateUrl) + config.ApiRootUrl = candidateUrl; + else logger.LogWarning("No server url found in the OpenAPI document. The base url will need to be set when using the client."); - return; - } - else if (!candidateUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) && config.OpenAPIFilePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - try - { - candidateUrl = new Uri(new Uri(config.OpenAPIFilePath), candidateUrl).ToString(); - } -#pragma warning disable CA1031 - catch (Exception ex) -#pragma warning restore CA1031 - { - logger.LogWarning(ex, "Could not resolve the server url from the OpenAPI document. The base url will need to be set when using the client."); - return; - } - } - config.ApiRootUrl = candidateUrl.TrimEnd(ForwardSlash); } private void StopLogAndReset(Stopwatch sw, string prefix) { @@ -393,128 +366,18 @@ private void StopLogAndReset(Stopwatch sw, string prefix) logger.LogDebug("{Prefix} {SwElapsed}", prefix, sw.Elapsed); sw.Reset(); } - - private static readonly AsyncKeyedLocker localFilesLock = new(o => - { - o.PoolSize = 20; - o.PoolInitialFill = 1; - }); private bool isDescriptionFromWorkspaceCopy; private async Task LoadStream(string inputPath, CancellationToken cancellationToken) { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - - inputPath = inputPath.Trim(); - - Stream input; - if (useKiotaConfig && - config.Operation is ClientOperation.Edit or ClientOperation.Add && - await workspaceManagementService.GetDescriptionCopyAsync(config.ClientClassName, inputPath, cancellationToken).ConfigureAwait(false) is { } descriptionStream) - { - logger.LogInformation("loaded description from the workspace copy"); - input = descriptionStream; - isDescriptionFromWorkspaceCopy = true; - } - else if (inputPath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - try - { - var cachingProvider = new DocumentCachingProvider(httpClient, logger) - { - ClearCache = config.ClearCache, - }; - var targetUri = APIsGuruSearchProvider.ChangeSourceUrlToGitHub(new Uri(inputPath)); // so updating existing clients doesn't break - var fileName = targetUri.GetFileName() is string name && !string.IsNullOrEmpty(name) ? name : "description.yml"; - input = await cachingProvider.GetDocumentAsync(targetUri, "generation", fileName, cancellationToken: cancellationToken).ConfigureAwait(false); - logger.LogInformation("loaded description from remote source"); - } - catch (HttpRequestException ex) - { - throw new InvalidOperationException($"Could not download the file at {inputPath}, reason: {ex.Message}", ex); - } - else - try - { -#pragma warning disable CA2000 // disposed by caller - var inMemoryStream = new MemoryStream(); - using (await localFilesLock.LockAsync(inputPath, cancellationToken).ConfigureAwait(false)) - {// To avoid deadlocking on update with multiple clients for the same local description - using var fileStream = new FileStream(inputPath, FileMode.Open); - await fileStream.CopyToAsync(inMemoryStream, cancellationToken).ConfigureAwait(false); - } - inMemoryStream.Position = 0; - input = inMemoryStream; - logger.LogInformation("loaded description from local source"); -#pragma warning restore CA2000 - } - catch (Exception ex) when (ex is FileNotFoundException || - ex is PathTooLongException || - ex is DirectoryNotFoundException || - ex is IOException || - ex is UnauthorizedAccessException || - ex is SecurityException || - ex is NotSupportedException) - { - throw new InvalidOperationException($"Could not open the file at {inputPath}, reason: {ex.Message}", ex); - } - stopwatch.Stop(); - logger.LogTrace("{Timestamp}ms: Read OpenAPI file {File}", stopwatch.ElapsedMilliseconds, inputPath); + var (input, isCopy) = await openApiDocumentDownloadService.LoadStreamAsync(inputPath, config, workspaceManagementService, useKiotaConfig, cancellationToken).ConfigureAwait(false); + isDescriptionFromWorkspaceCopy = isCopy; return input; } - private const char ForwardSlash = '/'; - public async Task CreateOpenApiDocumentAsync(Stream input, bool generating = false, CancellationToken cancellationToken = default) + internal const char ForwardSlash = '/'; + internal Task CreateOpenApiDocumentAsync(Stream input, bool generating = false, CancellationToken cancellationToken = default) { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - logger.LogTrace("Parsing OpenAPI file"); - var ruleSet = config.DisabledValidationRules.Contains(ValidationRuleSetExtensions.AllValidationRule) ? - ValidationRuleSet.GetEmptyRuleSet() : - ValidationRuleSet.GetDefaultRuleSet(); //workaround since validation rule set doesn't support clearing rules - if (generating) - ruleSet.AddKiotaValidationRules(config); - var settings = new OpenApiReaderSettings - { - RuleSet = ruleSet, - }; - settings.AddMicrosoftExtensionParsers(); - settings.ExtensionParsers.TryAdd(OpenApiKiotaExtension.Name, static (i, _) => OpenApiKiotaExtension.Parse(i)); - try - { - var rawUri = config.OpenAPIFilePath.TrimEnd(ForwardSlash); - var lastSlashIndex = rawUri.LastIndexOf(ForwardSlash); - if (lastSlashIndex < 0) - lastSlashIndex = rawUri.Length - 1; - var documentUri = new Uri(rawUri[..lastSlashIndex]); - settings.BaseUrl = documentUri; - settings.LoadExternalRefs = true; - } -#pragma warning disable CA1031 - catch -#pragma warning restore CA1031 - { - // couldn't parse the URL, it's probably a local file - } - var reader = new OpenApiStreamReader(settings); - var readResult = await reader.ReadAsync(input, cancellationToken).ConfigureAwait(false); - stopwatch.Stop(); - if (generating) - foreach (var warning in readResult.OpenApiDiagnostic.Warnings) - logger.LogWarning("OpenAPI warning: {Pointer} - {Warning}", warning.Pointer, warning.Message); - if (readResult.OpenApiDiagnostic.Errors.Any()) - { - logger.LogTrace("{Timestamp}ms: Parsed OpenAPI with errors. {Count} paths found.", stopwatch.ElapsedMilliseconds, readResult.OpenApiDocument?.Paths?.Count ?? 0); - foreach (var parsingError in readResult.OpenApiDiagnostic.Errors) - { - logger.LogError("OpenAPI error: {Pointer} - {Message}", parsingError.Pointer, parsingError.Message); - } - } - else - { - logger.LogTrace("{Timestamp}ms: Parsed OpenAPI successfully. {Count} paths found.", stopwatch.ElapsedMilliseconds, readResult.OpenApiDocument?.Paths?.Count ?? 0); - } - - return readResult.OpenApiDocument; + return openApiDocumentDownloadService.GetDocumentFromStreamAsync(input, config, generating, cancellationToken); } public static string GetDeeperMostCommonNamespaceNameForModels(OpenApiDocument document) { diff --git a/src/Kiota.Builder/Lock/LockManagementService.cs b/src/Kiota.Builder/Lock/LockManagementService.cs index 10e720835e..db4a17c30d 100644 --- a/src/Kiota.Builder/Lock/LockManagementService.cs +++ b/src/Kiota.Builder/Lock/LockManagementService.cs @@ -15,7 +15,7 @@ namespace Kiota.Builder.Lock; /// public class LockManagementService : ILockManagementService { - private const string LockFileName = "kiota-lock.json"; + internal const string LockFileName = "kiota-lock.json"; /// public IEnumerable GetDirectoriesContainingLockFile(string searchDirectory) { @@ -132,4 +132,11 @@ private static string GetBackupFilePath(string outputPath) var hashedPath = BitConverter.ToString((HashAlgorithm.Value ?? throw new InvalidOperationException("unable to get hash algorithm")).ComputeHash(Encoding.UTF8.GetBytes(outputPath))).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase); return Path.Combine(Path.GetTempPath(), Constants.TempDirectoryName, "backup", hashedPath, LockFileName); } + public void DeleteLockFile(string directoryPath) + { + ArgumentException.ThrowIfNullOrEmpty(directoryPath); + var lockFilePath = Path.Combine(directoryPath, LockFileName); + if (File.Exists(lockFilePath)) + File.Delete(lockFilePath); + } } diff --git a/src/Kiota.Builder/OpenApiDocumentDownloadService.cs b/src/Kiota.Builder/OpenApiDocumentDownloadService.cs new file mode 100644 index 0000000000..2ff8c3d180 --- /dev/null +++ b/src/Kiota.Builder/OpenApiDocumentDownloadService.cs @@ -0,0 +1,155 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security; +using System.Threading; +using System.Threading.Tasks; +using AsyncKeyedLock; +using Kiota.Builder.Caching; +using Kiota.Builder.Configuration; +using Kiota.Builder.Extensions; +using Kiota.Builder.OpenApiExtensions; +using Kiota.Builder.SearchProviders.APIsGuru; +using Kiota.Builder.Validation; +using Kiota.Builder.WorkspaceManagement; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Readers; +using Microsoft.OpenApi.Validations; + +namespace Kiota.Builder; +internal class OpenApiDocumentDownloadService +{ + private readonly ILogger Logger; + private readonly HttpClient HttpClient; + public OpenApiDocumentDownloadService(HttpClient httpClient, ILogger logger) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(logger); + HttpClient = httpClient; + Logger = logger; + } + private static readonly AsyncKeyedLocker localFilesLock = new(o => + { + o.PoolSize = 20; + o.PoolInitialFill = 1; + }); + internal async Task<(Stream, bool)> LoadStreamAsync(string inputPath, GenerationConfiguration config, WorkspaceManagementService? workspaceManagementService = default, bool useKiotaConfig = false, CancellationToken cancellationToken = default) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + inputPath = inputPath.Trim(); + + Stream input; + var isDescriptionFromWorkspaceCopy = false; + 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) + { + Logger.LogInformation("loaded description from the workspace copy"); + input = descriptionStream; + isDescriptionFromWorkspaceCopy = true; + } + else if (inputPath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + try + { + var cachingProvider = new DocumentCachingProvider(HttpClient, Logger) + { + ClearCache = config.ClearCache, + }; + var targetUri = APIsGuruSearchProvider.ChangeSourceUrlToGitHub(new Uri(inputPath)); // so updating existing clients doesn't break + var fileName = targetUri.GetFileName() is string name && !string.IsNullOrEmpty(name) ? name : "description.yml"; + input = await cachingProvider.GetDocumentAsync(targetUri, "generation", fileName, cancellationToken: cancellationToken).ConfigureAwait(false); + Logger.LogInformation("loaded description from remote source"); + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException($"Could not download the file at {inputPath}, reason: {ex.Message}", ex); + } + else + try + { + var inMemoryStream = new MemoryStream(); + using (await localFilesLock.LockAsync(inputPath, cancellationToken).ConfigureAwait(false)) + {// To avoid deadlocking on update with multiple clients for the same local description + using var fileStream = new FileStream(inputPath, FileMode.Open); + await fileStream.CopyToAsync(inMemoryStream, cancellationToken).ConfigureAwait(false); + } + inMemoryStream.Position = 0; + input = inMemoryStream; + Logger.LogInformation("loaded description from local source"); + } + catch (Exception ex) when (ex is FileNotFoundException || + ex is PathTooLongException || + ex is DirectoryNotFoundException || + ex is IOException || + ex is UnauthorizedAccessException || + ex is SecurityException || + ex is NotSupportedException) + { + throw new InvalidOperationException($"Could not open the file at {inputPath}, reason: {ex.Message}", ex); + } + stopwatch.Stop(); + Logger.LogTrace("{Timestamp}ms: Read OpenAPI file {File}", stopwatch.ElapsedMilliseconds, inputPath); + return (input, isDescriptionFromWorkspaceCopy); + } + + internal async Task GetDocumentFromStreamAsync(Stream input, GenerationConfiguration config, bool generating = false, CancellationToken cancellationToken = default) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + Logger.LogTrace("Parsing OpenAPI file"); + var ruleSet = config.DisabledValidationRules.Contains(ValidationRuleSetExtensions.AllValidationRule) ? + ValidationRuleSet.GetEmptyRuleSet() : + ValidationRuleSet.GetDefaultRuleSet(); //workaround since validation rule set doesn't support clearing rules + if (generating) + ruleSet.AddKiotaValidationRules(config); + var settings = new OpenApiReaderSettings + { + RuleSet = ruleSet, + }; + settings.AddMicrosoftExtensionParsers(); + settings.ExtensionParsers.TryAdd(OpenApiKiotaExtension.Name, static (i, _) => OpenApiKiotaExtension.Parse(i)); + try + { + var rawUri = config.OpenAPIFilePath.TrimEnd(KiotaBuilder.ForwardSlash); + var lastSlashIndex = rawUri.LastIndexOf(KiotaBuilder.ForwardSlash); + if (lastSlashIndex < 0) + lastSlashIndex = rawUri.Length - 1; + var documentUri = new Uri(rawUri[..lastSlashIndex]); + settings.BaseUrl = documentUri; + settings.LoadExternalRefs = true; + settings.LeaveStreamOpen = true; + } +#pragma warning disable CA1031 + catch +#pragma warning restore CA1031 + { + // couldn't parse the URL, it's probably a local file + } + var reader = new OpenApiStreamReader(settings); + var readResult = await reader.ReadAsync(input, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + if (generating) + foreach (var warning in readResult.OpenApiDiagnostic.Warnings) + Logger.LogWarning("OpenAPI warning: {Pointer} - {Warning}", warning.Pointer, warning.Message); + if (readResult.OpenApiDiagnostic.Errors.Any()) + { + Logger.LogTrace("{Timestamp}ms: Parsed OpenAPI with errors. {Count} paths found.", stopwatch.ElapsedMilliseconds, readResult.OpenApiDocument?.Paths?.Count ?? 0); + foreach (var parsingError in readResult.OpenApiDiagnostic.Errors) + { + Logger.LogError("OpenAPI error: {Pointer} - {Message}", parsingError.Pointer, parsingError.Message); + } + } + else + { + Logger.LogTrace("{Timestamp}ms: Parsed OpenAPI successfully. {Count} paths found.", stopwatch.ElapsedMilliseconds, readResult.OpenApiDocument?.Paths?.Count ?? 0); + } + + return readResult.OpenApiDocument; + } +} diff --git a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs index cf206d7437..46a3f98167 100644 --- a/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs +++ b/src/Kiota.Builder/WorkspaceManagement/ApiClientConfiguration.cs @@ -158,7 +158,8 @@ public object Clone() } public void NormalizePaths(string targetDirectory) { - OutputPath = "./" + Path.GetRelativePath(targetDirectory, OutputPath); + if (Path.IsPathRooted(OutputPath)) + OutputPath = "./" + Path.GetRelativePath(targetDirectory, OutputPath); } } #pragma warning restore CA2227 // Collection properties should be read only diff --git a/src/Kiota.Builder/WorkspaceManagement/DescriptionStorageService.cs b/src/Kiota.Builder/WorkspaceManagement/DescriptionStorageService.cs index 6facf131b6..1bdd930b69 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"; - private const string DescriptionsSubDirectoryRelativePath = $"{KiotaDirectorySegment}/clients"; + internal const string DescriptionsSubDirectoryRelativePath = $"{KiotaDirectorySegment}/clients"; private readonly string TargetDirectory; public DescriptionStorageService(string targetDirectory) { diff --git a/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs b/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs index 43b481c2a0..7874db312c 100644 --- a/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs +++ b/src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs @@ -2,11 +2,14 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; +using System.Net.Http; using System.Security.Cryptography; using System.Text; 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; @@ -21,9 +24,10 @@ public class WorkspaceManagementService { private readonly bool UseKiotaConfig; private readonly ILogger Logger; - public WorkspaceManagementService(ILogger logger, bool useKiotaConfig = false, string workingDirectory = "") + public WorkspaceManagementService(ILogger logger, HttpClient httpClient, bool useKiotaConfig = false, string workingDirectory = "") { ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(httpClient); Logger = logger; UseKiotaConfig = useKiotaConfig; if (string.IsNullOrEmpty(workingDirectory)) @@ -31,7 +35,9 @@ public WorkspaceManagementService(ILogger logger, bool useKiotaConfig = false, s WorkingDirectory = workingDirectory; workspaceConfigurationStorageService = new(workingDirectory); descriptionStorageService = new(workingDirectory); + openApiDocumentDownloadService = new(httpClient, Logger); } + private readonly OpenApiDocumentDownloadService openApiDocumentDownloadService; private readonly LockManagementService lockManagementService = new(); private readonly WorkspaceConfigurationStorageService workspaceConfigurationStorageService; private readonly DescriptionStorageService descriptionStorageService; @@ -46,9 +52,7 @@ public async Task UpdateStateFromConfigurationAsync(GenerationConfiguration gene ArgumentNullException.ThrowIfNull(generationConfiguration); if (UseKiotaConfig) { - var (wsConfig, manifest) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false); - wsConfig ??= new WorkspaceConfiguration(); - manifest ??= new ApiManifestDocument("application"); //TODO get the application name + var (wsConfig, manifest) = await LoadConfigurationAndManifestAsync(cancellationToken).ConfigureAwait(false); var generationClientConfig = new ApiClientConfiguration(generationConfiguration); generationClientConfig.NormalizePaths(WorkingDirectory); wsConfig.Clients.AddOrReplace(generationConfiguration.ClientClassName, generationClientConfig); @@ -172,4 +176,105 @@ private static string ConvertByteArrayToString(byte[] hash) return sb.ToString(); } + private async Task<(WorkspaceConfiguration, ApiManifestDocument)> LoadConfigurationAndManifestAsync(CancellationToken cancellationToken) + { + if (!await workspaceConfigurationStorageService.IsInitializedAsync(cancellationToken).ConfigureAwait(false)) + await workspaceConfigurationStorageService.InitializeAsync(cancellationToken).ConfigureAwait(false); + + var (wsConfig, apiManifest) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false); + if (wsConfig is null) + throw new InvalidOperationException("The workspace configuration is not initialized"); + apiManifest ??= new("application"); //TODO get the application name + return (wsConfig, apiManifest); + } + private async Task> LoadGenerationConfigurationsFromLockFilesAsync(string lockDirectory, string clientName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(lockDirectory); + if (!UseKiotaConfig) + throw new InvalidOperationException("Cannot migrate from lock file in kiota config mode"); + if (!Path.IsPathRooted(lockDirectory)) + lockDirectory = Path.Combine(WorkingDirectory, lockDirectory); + if (Path.GetRelativePath(WorkingDirectory, lockDirectory).StartsWith("..", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The lock directory must be a subdirectory of the working directory"); + + var lockFiles = Directory.GetFiles(lockDirectory, LockManagementService.LockFileName, SearchOption.AllDirectories); + if (lockFiles.Length == 0) + throw new InvalidOperationException("No lock file found in the specified directory"); + var clientNamePassed = !string.IsNullOrEmpty(clientName); + if (lockFiles.Length > 1 && clientNamePassed) + throw new InvalidOperationException("Multiple lock files found in the specified directory and the client name was specified"); + var clientsGenerationConfigurations = new List(); + if (lockFiles.Length == 1) + clientsGenerationConfigurations.Add(await LoadConfigurationFromLockAsync(clientNamePassed ? clientName : string.Empty, lockFiles[0], cancellationToken).ConfigureAwait(false)); + else + clientsGenerationConfigurations.AddRange(await Task.WhenAll(lockFiles.Select(x => LoadConfigurationFromLockAsync(string.Empty, x, cancellationToken))).ConfigureAwait(false)); + return clientsGenerationConfigurations.OfType().ToList(); + } + public async Task> MigrateFromLockFileAsync(string clientName, string lockDirectory, CancellationToken cancellationToken = default) + { + var (wsConfig, apiManifest) = await LoadConfigurationAndManifestAsync(cancellationToken).ConfigureAwait(false); + + var clientsGenerationConfigurations = await LoadGenerationConfigurationsFromLockFilesAsync(lockDirectory, clientName, cancellationToken).ConfigureAwait(false); + foreach (var generationConfiguration in clientsGenerationConfigurations.ToArray()) //to avoid modifying the collection as we iterate and remove some entries + { + + if (wsConfig.Clients.ContainsKey(generationConfiguration.ClientClassName)) + { + Logger.LogError("The client {ClientName} is already present in the configuration", generationConfiguration.ClientClassName); + clientsGenerationConfigurations.Remove(generationConfiguration); + continue; + } + var (stream, _) = await openApiDocumentDownloadService.LoadStreamAsync(generationConfiguration.OpenAPIFilePath, generationConfiguration, null, false, cancellationToken).ConfigureAwait(false); +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task + await using var msForOpenAPIDocument = new MemoryStream(); // openapi.net doesn't honour leave open + await using var ms = new MemoryStream(); +#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task + await stream.CopyToAsync(msForOpenAPIDocument, cancellationToken).ConfigureAwait(false); + msForOpenAPIDocument.Seek(0, SeekOrigin.Begin); + await msForOpenAPIDocument.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); + ms.Seek(0, SeekOrigin.Begin); + msForOpenAPIDocument.Seek(0, SeekOrigin.Begin); + var document = await openApiDocumentDownloadService.GetDocumentFromStreamAsync(msForOpenAPIDocument, generationConfiguration, false, cancellationToken).ConfigureAwait(false); + if (document is null) + { + Logger.LogError("The client {ClientName} could not be migrated because the OpenAPI document could not be loaded", generationConfiguration.ClientClassName); + clientsGenerationConfigurations.Remove(generationConfiguration); + continue; + } + generationConfiguration.ApiRootUrl = document.GetAPIRootUrl(generationConfiguration.OpenAPIFilePath); + await descriptionStorageService.UpdateDescriptionAsync(generationConfiguration.ClientClassName, ms, new Uri(generationConfiguration.OpenAPIFilePath).GetFileExtension(), cancellationToken).ConfigureAwait(false); + + var clientConfiguration = new ApiClientConfiguration(generationConfiguration); + clientConfiguration.NormalizePaths(WorkingDirectory); + wsConfig.Clients.Add(generationConfiguration.ClientClassName, clientConfiguration); + var inputConfigurationHash = await GetConfigurationHashAsync(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)); + } + await workspaceConfigurationStorageService.UpdateWorkspaceConfigurationAsync(wsConfig, apiManifest, cancellationToken).ConfigureAwait(false); + return clientsGenerationConfigurations.OfType().Select(static x => x.ClientClassName); + } + private async Task LoadConfigurationFromLockAsync(string clientName, string lockFilePath, CancellationToken cancellationToken) + { + if (Path.GetDirectoryName(lockFilePath) is not string lockFileDirectory) + { + Logger.LogWarning("The lock file {LockFilePath} is not in a directory, it will be skipped", lockFilePath); + return null; + } + var lockInfo = await lockManagementService.GetLockFromDirectoryAsync(lockFileDirectory, cancellationToken).ConfigureAwait(false); + if (lockInfo is null) + { + Logger.LogWarning("The lock file {LockFilePath} is not valid, it will be skipped", lockFilePath); + return null; + } + var generationConfiguration = new GenerationConfiguration(); + lockInfo.UpdateGenerationConfigurationFromLock(generationConfiguration); + generationConfiguration.OutputPath = "./" + Path.GetRelativePath(WorkingDirectory, lockFileDirectory); + if (!string.IsNullOrEmpty(clientName)) + { + generationConfiguration.ClientClassName = clientName; + } + return generationConfiguration; + } } diff --git a/src/kiota/Handlers/BaseKiotaCommandHandler.cs b/src/kiota/Handlers/BaseKiotaCommandHandler.cs index 8cc071ff86..32b0baef3a 100644 --- a/src/kiota/Handlers/BaseKiotaCommandHandler.cs +++ b/src/kiota/Handlers/BaseKiotaCommandHandler.cs @@ -243,6 +243,11 @@ _ when string.IsNullOrEmpty(version) => $"Example: kiota show -k {searchTerm} -- DisplayHint("Hint: use the --include-path and --exclude-path options with glob patterns to filter the paths displayed.", example); } } + protected void DisplayGenerateAfterMigrateHint() + { + DisplayHint("Hint: use the generate command to update the client and the manifest requests.", + "Example: kiota client generate"); + } protected void DisplaySearchAddHint() { DisplayHint("Hint: add your own API to the search result https://aka.ms/kiota/addapi."); diff --git a/src/kiota/Handlers/Client/RemoveHandler.cs b/src/kiota/Handlers/Client/RemoveHandler.cs index 1ad2352cf7..693c50f03d 100644 --- a/src/kiota/Handlers/Client/RemoveHandler.cs +++ b/src/kiota/Handlers/Client/RemoveHandler.cs @@ -29,7 +29,7 @@ public override async Task InvokeAsync(InvocationContext context) try { await CheckForNewVersionAsync(logger, cancellationToken).ConfigureAwait(false); - var workspaceManagementService = new WorkspaceManagementService(logger, true); + var workspaceManagementService = new WorkspaceManagementService(logger, httpClient, true); await workspaceManagementService.RemoveClientAsync(className, cleanOutput, cancellationToken).ConfigureAwait(false); return 0; } diff --git a/src/kiota/Handlers/Config/MigrateHandler.cs b/src/kiota/Handlers/Config/MigrateHandler.cs new file mode 100644 index 0000000000..cb84720b8b --- /dev/null +++ b/src/kiota/Handlers/Config/MigrateHandler.cs @@ -0,0 +1,54 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Kiota.Builder.WorkspaceManagement; +using Microsoft.Extensions.Logging; + +namespace kiota.Handlers.Config; + +internal class MigrateHandler : BaseKiotaCommandHandler +{ + public required Option LockDirectoryOption + { + get; + init; + } + public required Option ClassOption + { + get; init; + } + public override async Task InvokeAsync(InvocationContext context) + { + var workingDirectory = NormalizeSlashesInPath(Directory.GetCurrentDirectory()); + string lockDirectory = context.ParseResult.GetValueForOption(LockDirectoryOption) ?? workingDirectory; + string clientName = context.ParseResult.GetValueForOption(ClassOption) ?? string.Empty; + CancellationToken cancellationToken = context.BindingContext.GetService(typeof(CancellationToken)) is CancellationToken token ? token : CancellationToken.None; + lockDirectory = NormalizeSlashesInPath(lockDirectory); + var (loggerFactory, logger) = GetLoggerAndFactory(context, $"./{DescriptionStorageService.KiotaDirectorySegment}"); + using (loggerFactory) + { + try + { + var workspaceManagementService = new WorkspaceManagementService(logger, httpClient, true, workingDirectory); + var clientNames = await workspaceManagementService.MigrateFromLockFileAsync(clientName, lockDirectory, cancellationToken).ConfigureAwait(false); + if (!clientNames.Any()) + { + DisplayWarning("no client configuration was migrated"); + return 1; + } + DisplaySuccess($"Client configurations migrated successfully: {string.Join(", ", clientNames)}"); + DisplayGenerateAfterMigrateHint(); + return 0; + } + catch (Exception ex) + { + logger.LogCritical(ex, "error migrating the workspace configuration"); + return 1; + } + } + } +} diff --git a/src/kiota/KiotaClientCommands.cs b/src/kiota/KiotaClientCommands.cs index aac0085d29..07a3f998e4 100644 --- a/src/kiota/KiotaClientCommands.cs +++ b/src/kiota/KiotaClientCommands.cs @@ -20,11 +20,11 @@ private static Option GetSkipGenerationOption() skipGeneration.AddAlias("--sg"); return skipGeneration; } - private static Option GetClientNameOption() + internal static Option GetClientNameOption(bool required = true) { var clientName = new Option("--client-name", "The name of the client to manage") { - IsRequired = true, + IsRequired = required, }; clientName.AddAlias("--cn"); return clientName; diff --git a/src/kiota/KiotaConfigCommands.cs b/src/kiota/KiotaConfigCommands.cs index a69cdddcf3..59e0e8f63d 100644 --- a/src/kiota/KiotaConfigCommands.cs +++ b/src/kiota/KiotaConfigCommands.cs @@ -26,8 +26,27 @@ private static Command GetInitCommand() } private static Command GetMigrateCommand() { - var command = new Command("migrate", "Migrates a kiota lock file to a Kiota configuration"); - //TODO map the handler + var logLevelOption = KiotaHost.GetLogLevelOption(); + var lockDirectoryOption = GetLockDirectoryOption(); + var classOption = KiotaClientCommands.GetClientNameOption(false); + var command = new Command("migrate", "Migrates a kiota lock file to a Kiota configuration") + { + logLevelOption, + lockDirectoryOption, + classOption, + }; + command.Handler = new MigrateHandler + { + LogLevelOption = logLevelOption, + LockDirectoryOption = lockDirectoryOption, + ClassOption = classOption, + }; return command; } + private static Option GetLockDirectoryOption() + { + var option = new Option("--lock-directory", "The directory containing a kiota-lock.json file"); + option.AddAlias("--ld"); + return option; + } } diff --git a/tests/Kiota.Builder.Tests/Lock/LockManagementServiceTests.cs b/tests/Kiota.Builder.Tests/Lock/LockManagementServiceTests.cs index df6c5365ab..a00aafe3ff 100644 --- a/tests/Kiota.Builder.Tests/Lock/LockManagementServiceTests.cs +++ b/tests/Kiota.Builder.Tests/Lock/LockManagementServiceTests.cs @@ -52,4 +52,20 @@ public async Task UsesRelativePaths() await lockManagementService.WriteLockFileAsync(outputDirectory, lockFile); Assert.Equal($"..{Path.DirectorySeparatorChar}information{Path.DirectorySeparatorChar}description.yml", lockFile.DescriptionLocation, StringComparer.OrdinalIgnoreCase); } + [Fact] + public async Task DeletesALock() + { + var lockManagementService = new LockManagementService(); + var descriptionPath = Path.Combine(Path.GetTempPath(), "description.yml"); + var lockFile = new KiotaLock + { + ClientClassName = "foo", + ClientNamespaceName = "bar", + DescriptionLocation = descriptionPath, + }; + var path = Path.GetTempPath(); + await lockManagementService.WriteLockFileAsync(path, lockFile); + lockManagementService.DeleteLockFile(path); + Assert.Null(await lockManagementService.GetLockFromDirectoryAsync(path)); + } } diff --git a/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs b/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs index b4fb935c1d..f02053143d 100644 --- a/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs +++ b/tests/Kiota.Builder.Tests/WorkspaceManagement/WorkspaceManagementServiceTests.cs @@ -1,7 +1,10 @@ using System; using System.IO; +using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using Kiota.Builder.Configuration; +using Kiota.Builder.Lock; using Kiota.Builder.WorkspaceManagement; using Microsoft.Extensions.Logging; using Moq; @@ -12,19 +15,21 @@ namespace Kiota.Builder.Tests.WorkspaceManagement; public sealed class WorkspaceManagementServiceTests : IDisposable { private readonly string tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + private readonly HttpClient httpClient = new(); [Fact] public void Defensive() { - Assert.Throws(() => new WorkspaceManagementService(null)); + Assert.Throws(() => new WorkspaceManagementService(null, httpClient)); + Assert.Throws(() => new WorkspaceManagementService(Mock.Of(), null)); } [InlineData(true)] [InlineData(false)] [Theory] public async Task IsClientPresentReturnsFalseOnNoClient(bool usesConfig) { - var mockLogger = new Mock(); + var mockLogger = Mock.Of(); Directory.CreateDirectory(tempPath); - var service = new WorkspaceManagementService(mockLogger.Object, usesConfig, tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, usesConfig, tempPath); var result = await service.IsClientPresent("clientName"); Assert.False(result); } @@ -33,9 +38,9 @@ public async Task IsClientPresentReturnsFalseOnNoClient(bool usesConfig) [Theory] public async Task ShouldGenerateReturnsTrue(bool usesConfig) { - var mockLogger = new Mock(); + var mockLogger = Mock.Of(); Directory.CreateDirectory(tempPath); - var service = new WorkspaceManagementService(mockLogger.Object, usesConfig, tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, usesConfig, tempPath); var configuration = new GenerationConfiguration { ClientClassName = "clientName", @@ -50,9 +55,9 @@ public async Task ShouldGenerateReturnsTrue(bool usesConfig) [Theory] public async Task ShouldGenerateReturnsFalse(bool usesConfig) { - var mockLogger = new Mock(); + var mockLogger = Mock.Of(); Directory.CreateDirectory(tempPath); - var service = new WorkspaceManagementService(mockLogger.Object, usesConfig, tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, usesConfig, tempPath); var configuration = new GenerationConfiguration { ClientClassName = "clientName", @@ -68,9 +73,9 @@ public async Task ShouldGenerateReturnsFalse(bool usesConfig) [Fact] public async Task RemovesAClient() { - var mockLogger = new Mock(); + var mockLogger = Mock.Of(); Directory.CreateDirectory(tempPath); - var service = new WorkspaceManagementService(mockLogger.Object, true, tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath); var configuration = new GenerationConfiguration { ClientClassName = "clientName", @@ -84,10 +89,94 @@ public async Task RemovesAClient() var result = await service.IsClientPresent("clientName"); Assert.False(result); } + [Fact] + public async Task FailsOnMigrateWithoutKiotaConfigMode() + { + var mockLogger = Mock.Of(); + Directory.CreateDirectory(tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, false, tempPath); + await Assert.ThrowsAsync(() => service.MigrateFromLockFileAsync(string.Empty, tempPath)); + } + [Fact] + public async Task FailsWhenTargetLockDirectoryIsNotSubDirectory() + { + var mockLogger = Mock.Of(); + Directory.CreateDirectory(tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath); + await Assert.ThrowsAsync(() => service.MigrateFromLockFileAsync(string.Empty, Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()))); + } + [Fact] + public async Task FailsWhenNoLockFilesAreFound() + { + var mockLogger = Mock.Of(); + Directory.CreateDirectory(tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath); + await Assert.ThrowsAsync(() => service.MigrateFromLockFileAsync(string.Empty, tempPath)); + } + [Fact] + public async Task FailsOnMultipleLockFilesAndClientName() + { + var mockLogger = Mock.Of(); + Directory.CreateDirectory(tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath); + Directory.CreateDirectory(Path.Combine(tempPath, "client1")); + Directory.CreateDirectory(Path.Combine(tempPath, "client2")); + File.WriteAllText(Path.Combine(tempPath, "client1", LockManagementService.LockFileName), "foo"); + File.WriteAllText(Path.Combine(tempPath, "client2", LockManagementService.LockFileName), "foo"); + await Assert.ThrowsAsync(() => service.MigrateFromLockFileAsync("bar", tempPath)); + } + [Fact] + public async Task MigratesAClient() + { + var mockLogger = Mock.Of(); + Directory.CreateDirectory(tempPath); + var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath); + var descriptionPath = Path.Combine(tempPath, "description.yml"); + var generationConfiguration = new GenerationConfiguration + { + ClientClassName = "clientName", + OutputPath = Path.Combine(tempPath, "client"), + OpenAPIFilePath = descriptionPath, + ApiRootUrl = "https://graph.microsoft.com", + }; + Directory.CreateDirectory(generationConfiguration.OutputPath); + 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 classicService = new WorkspaceManagementService(mockLogger, httpClient, false, tempPath); + await classicService.UpdateStateFromConfigurationAsync(generationConfiguration, "foo", [], Stream.Null); + var clientNames = await service.MigrateFromLockFileAsync("clientName", tempPath); + Assert.Single(clientNames); + Assert.Equal("clientName", clientNames.First()); + Assert.False(File.Exists(Path.Combine(tempPath, LockManagementService.LockFileName))); + Assert.True(File.Exists(Path.Combine(tempPath, WorkspaceConfigurationStorageService.ConfigurationFileName))); + Assert.True(File.Exists(Path.Combine(tempPath, WorkspaceConfigurationStorageService.ManifestFileName))); + Assert.True(File.Exists(Path.Combine(tempPath, DescriptionStorageService.DescriptionsSubDirectoryRelativePath, "clientName.yml"))); + } public void Dispose() { if (Directory.Exists(tempPath)) Directory.Delete(tempPath, true); + httpClient.Dispose(); } }