From b828850b45fd50401a90c0855693c01fa6edca36 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Sun, 15 Sep 2024 21:39:21 -0700 Subject: [PATCH] Adding flow API command This provide a hidden Velopack Flow API command. This allows for easier scripting directly against the API as it leveraging the built in auth. --- src/vpk/Velopack.Build/PublishTask.cs | 2 +- .../Flow/VelopackFlowServiceClient.cs | 58 ++++++++++++++++--- .../HttpClientExtensions.cs | 5 ++ .../Velopack.Vpk/Commands/Flow/ApiCommand.cs | 39 +++++++++++++ .../Commands/Flow/ApiCommandRunner.cs | 26 +++++++++ .../Velopack.Vpk/Commands/Flow/ApiOptions.cs | 11 ++++ .../Commands/Flow/LoginCommandRunner.cs | 2 +- .../Commands/Flow/PublishCommandRunner.cs | 2 +- src/vpk/Velopack.Vpk/Commands/_BaseCommand.cs | 4 ++ ...reConsoleSink.cs => SpectreConsoleSink.cs} | 8 +-- src/vpk/Velopack.Vpk/OptionMapper.cs | 1 + src/vpk/Velopack.Vpk/Program.cs | 14 ++++- 12 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 src/vpk/Velopack.Vpk/Commands/Flow/ApiCommand.cs create mode 100644 src/vpk/Velopack.Vpk/Commands/Flow/ApiCommandRunner.cs create mode 100644 src/vpk/Velopack.Vpk/Commands/Flow/ApiOptions.cs rename src/vpk/Velopack.Vpk/Logging/{MySpectreConsoleSink.cs => SpectreConsoleSink.cs} (89%) diff --git a/src/vpk/Velopack.Build/PublishTask.cs b/src/vpk/Velopack.Build/PublishTask.cs index f59467f4..13537235 100644 --- a/src/vpk/Velopack.Build/PublishTask.cs +++ b/src/vpk/Velopack.Build/PublishTask.cs @@ -34,7 +34,7 @@ public class PublishTask : MSBuildAsyncTask AllowInteractiveLogin = false, VelopackBaseUrl = ServiceUrl, ApiKey = ApiKey - }, cancellationToken).ConfigureAwait(false)) { + }, false, cancellationToken).ConfigureAwait(false)) { Logger.LogWarning("Not logged into Velopack Flow service, skipping publish. Please run vpk login."); return true; } diff --git a/src/vpk/Velopack.Packaging/Flow/VelopackFlowServiceClient.cs b/src/vpk/Velopack.Packaging/Flow/VelopackFlowServiceClient.cs index 07cc1a93..cf934b60 100644 --- a/src/vpk/Velopack.Packaging/Flow/VelopackFlowServiceClient.cs +++ b/src/vpk/Velopack.Packaging/Flow/VelopackFlowServiceClient.cs @@ -4,6 +4,8 @@ using NuGet.Versioning; using Microsoft.Extensions.Logging; using System.Text; using System.IO; +using Markdig.Helpers; + #if NET6_0_OR_GREATER @@ -17,11 +19,15 @@ namespace Velopack.Packaging.Flow; public interface IVelopackFlowServiceClient { - Task LoginAsync(VelopackLoginOptions? options, CancellationToken cancellationToken); + Task LoginAsync(VelopackLoginOptions? options, bool suppressOutput, CancellationToken cancellationToken); Task LogoutAsync(VelopackServiceOptions? options, CancellationToken cancellationToken); Task GetProfileAsync(VelopackServiceOptions? options, CancellationToken cancellationToken); + Task InvokeEndpointAsync(VelopackServiceOptions? options, string endpointUri, + string method, + string? body, + CancellationToken cancellationToken); Task UploadLatestReleaseAssetsAsync(string? channel, string releaseDirectory, string? serviceUrl, RuntimeOs os, CancellationToken cancellationToken); } @@ -33,10 +39,12 @@ public class VelopackFlowServiceClient(HttpClient HttpClient, ILogger Logger) : private AuthConfiguration? AuthConfiguration { get; set; } - public async Task LoginAsync(VelopackLoginOptions? options, CancellationToken cancellationToken) + public async Task LoginAsync(VelopackLoginOptions? options, bool suppressOutput, CancellationToken cancellationToken) { options ??= new VelopackLoginOptions(); - Logger.LogInformation("Preparing to login to Velopack ({ServiceUrl})", options.VelopackBaseUrl); + if (!suppressOutput) { + Logger.LogInformation("Preparing to login to Velopack ({ServiceUrl})", options.VelopackBaseUrl); + } var authConfiguration = await GetAuthConfigurationAsync(options, cancellationToken); @@ -45,7 +53,9 @@ public class VelopackFlowServiceClient(HttpClient HttpClient, ILogger Logger) : if (!string.IsNullOrWhiteSpace(options.ApiKey)) { HttpClient.DefaultRequestHeaders.Authorization = new(HmacHelper.HmacScheme, options.ApiKey); var profile = await GetProfileAsync(options, cancellationToken); - Logger.LogInformation("{UserName} logged into Velopack with API key", profile?.GetDisplayName()); + if (!suppressOutput) { + Logger.LogInformation("{UserName} logged into Velopack with API key", profile?.GetDisplayName()); + } return true; } else { AuthenticationResult? rv = null; @@ -63,7 +73,9 @@ public class VelopackFlowServiceClient(HttpClient HttpClient, ILogger Logger) : HttpClient.DefaultRequestHeaders.Authorization = new("Bearer", rv.IdToken ?? rv.AccessToken); var profile = await GetProfileAsync(options, cancellationToken); - Logger.LogInformation("{UserName} logged into Velopack", profile?.GetDisplayName()); + if (!suppressOutput) { + Logger.LogInformation("{UserName} logged into Velopack", profile?.GetDisplayName()); + } return true; } else { Logger.LogError("Failed to login to Velopack"); @@ -94,6 +106,35 @@ public class VelopackFlowServiceClient(HttpClient HttpClient, ILogger Logger) : return await HttpClient.GetFromJsonAsync(endpoint, cancellationToken); } + public async Task InvokeEndpointAsync( + VelopackServiceOptions? options, + string endpointUri, + string method, + string? body, + CancellationToken cancellationToken) + { + AssertAuthenticated(); + var endpoint = GetEndpoint(endpointUri, options?.VelopackBaseUrl); + + HttpRequestMessage request = new(new HttpMethod(method), endpoint); + if (body is not null) { + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + HttpResponseMessage response = await HttpClient.SendAsync(request, cancellationToken); + +#if NET6_0_OR_GREATER + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken); +#else + string responseBody = await response.Content.ReadAsStringAsync(); +#endif + + if (response.IsSuccessStatusCode) { + return responseBody; + } else { + throw new InvalidOperationException($"Failed to invoke endpoint {endpointUri} with status code {response.StatusCode}{Environment.NewLine}{responseBody}"); + } + } + public async Task UploadLatestReleaseAssetsAsync(string? channel, string releaseDirectory, string? serviceUrl, RuntimeOs os, CancellationToken cancellationToken) { @@ -164,7 +205,10 @@ public class VelopackFlowServiceClient(HttpClient HttpClient, ILogger Logger) : var endpoint = GetEndpoint("v1/releaseGroups/create", velopackBaseUrl); var response = await HttpClient.PostAsJsonAsync(endpoint, request, cancellationToken); - response.EnsureSuccessStatusCode(); + if (!response.IsSuccessStatusCode) { + string content = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException($"Failed to create release group with version {version.ToNormalizedString()}{Environment.NewLine}Response status code: {response.StatusCode}{Environment.NewLine}{content}"); + } return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) ?? throw new InvalidOperationException($"Failed to create release group with version {version.ToNormalizedString()}"); @@ -182,7 +226,7 @@ public class VelopackFlowServiceClient(HttpClient HttpClient, ILogger Logger) : var latestPath = Path.Combine(releaseDirectory, fileName); using var fileStream = File.OpenRead(latestPath); - + using var fileContent = new StreamContent(fileStream); formData.Add(fileContent, "File", fileName); diff --git a/src/vpk/Velopack.Packaging/HttpClientExtensions.cs b/src/vpk/Velopack.Packaging/HttpClientExtensions.cs index cd94e41c..771d0608 100644 --- a/src/vpk/Velopack.Packaging/HttpClientExtensions.cs +++ b/src/vpk/Velopack.Packaging/HttpClientExtensions.cs @@ -36,5 +36,10 @@ public static class HttpClientExtensions var json = await content.ReadAsStringAsync(); return Newtonsoft.Json.JsonConvert.DeserializeObject(json); } + + public static async Task ReadAsStringAsync(this HttpContent content, CancellationToken _) + { + return await content.ReadAsStringAsync(); + } } #endif diff --git a/src/vpk/Velopack.Vpk/Commands/Flow/ApiCommand.cs b/src/vpk/Velopack.Vpk/Commands/Flow/ApiCommand.cs new file mode 100644 index 00000000..8af9fa5c --- /dev/null +++ b/src/vpk/Velopack.Vpk/Commands/Flow/ApiCommand.cs @@ -0,0 +1,39 @@ +#nullable enable + +using System.Net.Http; +using Serilog.Core; + +namespace Velopack.Vpk.Commands.Flow; +public class ApiCommand : VelopackServiceCommand +{ + public string Method { get; private set; } = ""; + + public string Endpoint { get; private set; } = ""; + + public string? Body { get; private set; } + + public ApiCommand() + : base("api", "Invoke velopack flow API endpoints") + { + AddOption(v => Method = v, "--method", "-m") + .SetDescription("The HTTP method for the endpoint") + .SetArgumentHelpName("METHOD") + .SetRequired() + .SetDefault(HttpMethod.Get.Method); + + AddOption(v => Endpoint = v, "--endpoint", "-e") + .SetDescription("The relative URI for the endpoint") + .SetArgumentHelpName("URI") + .SetRequired(); + + AddOption(v => Body = v, "--body", "-b") + .SetDescription("The body of the HTTP message") + .SetArgumentHelpName("BODY"); + } + + public override void Initialize(LoggingLevelSwitch logLevelSwitch) + { + base.Initialize(logLevelSwitch); + logLevelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Warning; + } +} diff --git a/src/vpk/Velopack.Vpk/Commands/Flow/ApiCommandRunner.cs b/src/vpk/Velopack.Vpk/Commands/Flow/ApiCommandRunner.cs new file mode 100644 index 00000000..6802f388 --- /dev/null +++ b/src/vpk/Velopack.Vpk/Commands/Flow/ApiCommandRunner.cs @@ -0,0 +1,26 @@ +using System.Threading; +using Serilog.Core; +using Velopack.Packaging.Abstractions; +using Velopack.Packaging.Flow; + +namespace Velopack.Vpk.Commands.Flow; +public class ApiCommandRunner(IVelopackFlowServiceClient Client) : ICommand +{ + public async Task Run(ApiOptions options) + { + CancellationToken token = CancellationToken.None; + if (!await Client.LoginAsync(new VelopackLoginOptions() { + AllowCacheCredentials = true, + AllowDeviceCodeFlow = false, + AllowInteractiveLogin = false, + ApiKey = options.ApiKey, + VelopackBaseUrl = options.VelopackBaseUrl + }, true, token)) { + return; + } + + string response = await Client.InvokeEndpointAsync(options, options.Endpoint, options.Method, options.Body, token); + Console.WriteLine(response); + } +} + diff --git a/src/vpk/Velopack.Vpk/Commands/Flow/ApiOptions.cs b/src/vpk/Velopack.Vpk/Commands/Flow/ApiOptions.cs new file mode 100644 index 00000000..5c826f8a --- /dev/null +++ b/src/vpk/Velopack.Vpk/Commands/Flow/ApiOptions.cs @@ -0,0 +1,11 @@ +#nullable enable +using Velopack.Packaging.Flow; + +namespace Velopack.Vpk.Commands.Flow; + +public sealed class ApiOptions : VelopackServiceOptions +{ + public string Endpoint { get; set; } = ""; + public string Method { get; set; } = ""; + public string? Body { get; set; } +} \ No newline at end of file diff --git a/src/vpk/Velopack.Vpk/Commands/Flow/LoginCommandRunner.cs b/src/vpk/Velopack.Vpk/Commands/Flow/LoginCommandRunner.cs index f6ad3d27..381cca82 100644 --- a/src/vpk/Velopack.Vpk/Commands/Flow/LoginCommandRunner.cs +++ b/src/vpk/Velopack.Vpk/Commands/Flow/LoginCommandRunner.cs @@ -12,6 +12,6 @@ public class LoginCommandRunner(IVelopackFlowServiceClient Client) : ICommand(provider)); HideCommand(rootCommand.AddCommand(provider)); + var flowCommand = new CliCommand("flow", "Commands for interacting with Velopack Flow.") { Hidden = true }; + HideCommand(flowCommand.AddCommand(provider)); + rootCommand.Add(flowCommand); + var cli = new CliConfiguration(rootCommand); return await cli.InvokeAsync(args); @@ -173,8 +178,11 @@ public class Program private static void SetupLogging(IHostApplicationBuilder builder, bool verbose, bool legacyConsole) { + var levelSwitch = new LoggingLevelSwitch { + MinimumLevel = verbose ? LogEventLevel.Debug : LogEventLevel.Information + }; var conf = new LoggerConfiguration() - .MinimumLevel.Is(verbose ? LogEventLevel.Debug : LogEventLevel.Information) + .MinimumLevel.ControlledBy(levelSwitch) .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("System", LogEventLevel.Warning); @@ -186,6 +194,7 @@ public class Program builder.Services.AddSingleton(); conf.WriteTo.Spectre(); } + builder.Services.AddSingleton(levelSwitch); builder.Services.AddSingleton(sp => sp.GetRequiredService()); Log.Logger = conf.CreateLogger(); @@ -247,6 +256,9 @@ public static class ProgramCommandExtensions var console = provider.GetRequiredService(); var config = provider.GetRequiredService(); var defaults = provider.GetRequiredService(); + var logLevelSwitch = provider.GetRequiredService(); + + command.Initialize(logLevelSwitch); logger.LogInformation($"[bold]{Program.INTRO}[/]"); var updateCheck = new UpdateChecker(logger);