diff --git a/DevProxy.Abstractions/Plugins/BasePlugin.cs b/DevProxy.Abstractions/Plugins/BasePlugin.cs index 7f47ce16f..7d28825b6 100644 --- a/DevProxy.Abstractions/Plugins/BasePlugin.cs +++ b/DevProxy.Abstractions/Plugins/BasePlugin.cs @@ -154,7 +154,15 @@ public override async Task InitializeAsync(InitArgs e, CancellationToken cancell { if (Logger.IsEnabled(LogLevel.Error)) { - Logger.LogError("Plugin configuration validation failed with the following errors: {Errors}", string.Join(", ", ValidationErrors)); + var docsLink = DocsLinks.ForPlugin(GetType()); + if (docsLink is null) + { + Logger.LogError("Plugin configuration validation failed with the following errors: {Errors}", string.Join(", ", ValidationErrors)); + } + else + { + Logger.LogError("Plugin configuration validation failed with the following errors: {Errors}. Docs: {DocsUrl}", string.Join(", ", ValidationErrors).TrimEnd('.'), docsLink); + } } } } diff --git a/DevProxy.Abstractions/Utils/DocsLinks.cs b/DevProxy.Abstractions/Utils/DocsLinks.cs new file mode 100644 index 000000000..5ad88111d --- /dev/null +++ b/DevProxy.Abstractions/Utils/DocsLinks.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Reflection; + +namespace DevProxy.Abstractions.Utils; + +/// +/// Builds stable documentation shortlinks (https://go.devproxy.net?go=<code>). +/// The shortlink service redirects codes to the actual documentation pages, +/// so the CLI never hard-codes documentation paths. +/// +public static class DocsLinks +{ + private const string BaseUrl = "https://go.devproxy.net?go="; + private const string BuiltInPluginsAssemblyName = "DevProxy.Plugins"; + + public static string Api { get; } = Get("api"); + public static string Cli { get; } = Get("cli"); + + public static string Get(string code) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + return BaseUrl + Uri.EscapeDataString(code.ToLowerInvariant()); + } + + public static bool IsBuiltInPluginAssembly(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + return string.Equals(assembly.GetName().Name, BuiltInPluginsAssemblyName, StringComparison.Ordinal); + } + + /// + /// Returns the documentation link for a plugin, or null if the plugin + /// isn't shipped with Dev Proxy and has no canonical documentation page. + /// + public static string? ForPlugin(Type pluginType) + { + ArgumentNullException.ThrowIfNull(pluginType); + return IsBuiltInPluginAssembly(pluginType.Assembly) ? Get(pluginType.Name) : null; + } +} diff --git a/DevProxy.Tests/HelpDocsTests.cs b/DevProxy.Tests/HelpDocsTests.cs new file mode 100644 index 000000000..b5fff5237 --- /dev/null +++ b/DevProxy.Tests/HelpDocsTests.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using DevProxy.Commands; +using System.CommandLine; +using Xunit; + +namespace DevProxy.Tests; + +public sealed class HelpDocsTests +{ + private static RootCommand CreateRootCommand(out Command pluginCommand) + { + var root = new RootCommand(); + var api = new Command("api"); + var show = new Command("show"); + show.SetAction(_ => { }); + api.Subcommands.Add(show); + root.Subcommands.Add(api); + + pluginCommand = new Command("mocks"); + var pluginSubcommand = new Command("from-http-responses"); + pluginSubcommand.SetAction(_ => { }); + pluginCommand.Subcommands.Add(pluginSubcommand); + root.Subcommands.Add(pluginCommand); + + return root; + } + + [Fact] + public void GetLink_RootCommand_ReturnsCliLink() + { + var root = CreateRootCommand(out _); + + Assert.Equal("https://go.devproxy.net?go=cli", HelpDocs.GetLink(root.Parse(""))); + } + + [Fact] + public void GetLink_LeafCommand_ReturnsCommandPathLink() + { + var root = CreateRootCommand(out _); + + Assert.Equal("https://go.devproxy.net?go=cli-api-show", HelpDocs.GetLink(root.Parse("api show"))); + } + + [Fact] + public void GetLink_GroupCommand_ReturnsCliLink() + { + var root = CreateRootCommand(out _); + + Assert.Equal("https://go.devproxy.net?go=cli", HelpDocs.GetLink(root.Parse("api"))); + } + + [Fact] + public void GetLink_CommandWithExplicitLink_AppliesToSubcommands() + { + var root = CreateRootCommand(out var pluginCommand); + HelpDocs.Set(pluginCommand, DocsLinks.Get("MockResponsePlugin")); + + Assert.Equal("https://go.devproxy.net?go=mockresponseplugin", HelpDocs.GetLink(root.Parse("mocks"))); + Assert.Equal("https://go.devproxy.net?go=mockresponseplugin", HelpDocs.GetLink(root.Parse("mocks from-http-responses"))); + } + + [Fact] + public void GetLink_CommandWithoutDocs_ReturnsNull() + { + var root = CreateRootCommand(out var pluginCommand); + HelpDocs.Set(pluginCommand, null); + + Assert.Null(HelpDocs.GetLink(root.Parse("mocks from-http-responses"))); + } + + [Fact] + public void ForPlugin_ThirdPartyPlugin_ReturnsNull() + { + Assert.Null(DocsLinks.ForPlugin(typeof(HelpDocsTests))); + } +} diff --git a/DevProxy/Commands/ApiCommand.cs b/DevProxy/Commands/ApiCommand.cs index 78119ddce..94d47259b 100644 --- a/DevProxy/Commands/ApiCommand.cs +++ b/DevProxy/Commands/ApiCommand.cs @@ -145,7 +145,8 @@ private void PrintApiInfo(OutputFormat outputFormat) method = e.Method, path = e.Path, description = e.Description - }) + }), + docs = DocsLinks.Api }, ProxyUtils.JsonSerializerOptions); _logger.LogStructuredOutput(json); } @@ -161,6 +162,8 @@ private void PrintApiInfo(OutputFormat outputFormat) { _logger.LogInformation(" {Method,-6} {Path,-30} {Description}", endpoint.Method, endpoint.Path, endpoint.Description); } + _logger.LogInformation(""); + _logger.LogInformation("Docs: {DocsUrl}", DocsLinks.Api); } } } diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index dfc9b7637..bf3bf7eb5 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -638,10 +638,11 @@ private void ConfigureCommand() ActivatorUtilities.CreateInstance(_serviceProvider), ActivatorUtilities.CreateInstance(_serviceProvider) }; - var pluginCommands = _plugins.SelectMany(p => p.GetCommands()); - foreach (var group in pluginCommands.GroupBy(c => c.Name)) + var pluginCommands = _plugins.SelectMany(p => p.GetCommands().Select(c => (Plugin: p, Command: c))); + foreach (var group in pluginCommands.GroupBy(pc => pc.Command.Name)) { - var command = group.First(); + var (plugin, command) = group.First(); + HelpDocs.Set(command, DocsLinks.ForPlugin(plugin.GetType())); commands.Add(command); if (group.Count() > 1) { diff --git a/DevProxy/Commands/DevProxyHelpAction.cs b/DevProxy/Commands/DevProxyHelpAction.cs index c5ec0dde2..c09df7ba3 100644 --- a/DevProxy/Commands/DevProxyHelpAction.cs +++ b/DevProxy/Commands/DevProxyHelpAction.cs @@ -14,13 +14,24 @@ public override int Invoke(ParseResult parseResult) { originalAction.Invoke(parseResult); - if (parseResult.CommandResult.Command is not RootCommand) + var output = Console.Out; + + if (parseResult.CommandResult.Command is RootCommand) { - return 0; + WriteRootHelp(output); } - var output = Console.Out; + var docsLink = HelpDocs.GetLink(parseResult); + if (docsLink is not null) + { + output.WriteLine($"Docs: {docsLink}"); + } + + return 0; + } + private static void WriteRootHelp(TextWriter output) + { output.WriteLine(); output.WriteLine(" Additional commands may be available depending on configured plugins."); output.WriteLine(" Use -c to load a specific configuration."); @@ -36,7 +47,6 @@ public override int Invoke(ParseResult parseResult) output.WriteLine(" 0 Success"); output.WriteLine(" 1 Runtime error"); output.WriteLine(" 2 Invalid input or usage"); - - return 0; + output.WriteLine(); } } diff --git a/DevProxy/Commands/HelpDocs.cs b/DevProxy/Commands/HelpDocs.cs new file mode 100644 index 000000000..1b4a9feec --- /dev/null +++ b/DevProxy/Commands/HelpDocs.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Utils; +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Runtime.CompilerServices; + +namespace DevProxy.Commands; + +static class HelpDocs +{ + // Commands with an explicit docs link (for example contributed by plugins). + // A null value means the command has no canonical docs page. + private static readonly ConditionalWeakTable> _links = new(); + + public static void Set(Command command, string? link, bool recursive = true) + { + _links.AddOrUpdate(command, new(link)); + if (!recursive) + { + return; + } + + foreach (var subcommand in command.Subcommands) + { + Set(subcommand, link, recursive); + } + } + + public static string? GetLink(ParseResult parseResult) + { + var command = parseResult.CommandResult.Command; + if (_links.TryGetValue(command, out var link)) + { + return link.Value; + } + + if (command is RootCommand || command.Action is null) + { + return DocsLinks.Cli; + } + + var path = new List(); + for (var result = parseResult.CommandResult; result is not null; result = result.Parent as CommandResult) + { + if (result.Command is RootCommand) + { + break; + } + path.Insert(0, result.Command.Name); + } + + return DocsLinks.Get($"cli-{string.Join('-', path)}"); + } +} diff --git a/DevProxy/Plugins/PluginServiceExtensions.cs b/DevProxy/Plugins/PluginServiceExtensions.cs index c8a292e3c..07a3977ce 100644 --- a/DevProxy/Plugins/PluginServiceExtensions.cs +++ b/DevProxy/Plugins/PluginServiceExtensions.cs @@ -130,7 +130,15 @@ public static IServiceCollection AddPlugins( } catch (Exception ex) { - logger.LogError(ex, "Failed to load plugin {PluginName}: {ErrorMessage}", pluginRef.Name, ex.Message); + var docsLink = DocsLinks.IsBuiltInPluginAssembly(assembly) ? DocsLinks.Get(pluginRef.Name) : null; + if (docsLink is null) + { + logger.LogError(ex, "Failed to load plugin {PluginName}: {ErrorMessage}", pluginRef.Name, ex.Message); + } + else + { + logger.LogError(ex, "Failed to load plugin {PluginName}: {ErrorMessage}. Docs: {DocsUrl}", pluginRef.Name, ex.Message.TrimEnd('.'), docsLink); + } } } @@ -165,7 +173,15 @@ private static void RegisterPlugin( // communicate through STDIO (JSON-RPC) rather than HTTP if (urlsToWatch.Count == 0 && !isStdioMode) { - logger.LogError("Plugin {PluginName} must have at least one URL to watch. Please add a URL to watch in the configuration file or use the --urls-to-watch option.", pluginRef.Name); + var docsLink = DocsLinks.ForPlugin(pluginType); + if (docsLink is null) + { + logger.LogError("Plugin {PluginName} must have at least one URL to watch. Please add a URL to watch in the configuration file or use the --urls-to-watch option.", pluginRef.Name); + } + else + { + logger.LogError("Plugin {PluginName} must have at least one URL to watch. Please add a URL to watch in the configuration file or use the --urls-to-watch option. Docs: {DocsUrl}", pluginRef.Name, docsLink); + } return; } @@ -177,12 +193,7 @@ private static void RegisterPlugin( { var plugin = (IPlugin)ActivatorUtilities.CreateInstance(sp, pluginType, urlsToWatch) ?? throw new InvalidOperationException($"Failed to create instance of plugin type {pluginType}"); - // we don't have a cancellation token right now, because this - // stage is synchronous, but if the implementation changes - // in the future then we won't need to change the interface -#pragma warning disable VSTHRD002 - plugin.InitializeAsync(new() { ServiceProvider = sp, IsProxyCommand = DevProxyCommand.IsRootCommand }, CancellationToken.None).Wait(); -#pragma warning restore VSTHRD002 + InitializePlugin(plugin, sp, logger); return plugin; }); logger.LogDebug("Registered plugin {PluginName}.", pluginRef.Name); @@ -196,15 +207,33 @@ private static void RegisterPlugin( { var plugin = (IPlugin)ActivatorUtilities.CreateInstance(sp, pluginType, configSection, urlsToWatch) ?? throw new InvalidOperationException($"Failed to create instance of plugin type {pluginType}"); + InitializePlugin(plugin, sp, logger); + return plugin; + }); + logger.LogDebug("Registered plugin {PluginName}.", pluginRef.Name); + } + + private static void InitializePlugin(IPlugin plugin, IServiceProvider serviceProvider, ILogger logger) + { + try + { // we don't have a cancellation token right now, because this // stage is synchronous, but if the implementation changes // in the future then we won't need to change the interface #pragma warning disable VSTHRD002 - plugin.InitializeAsync(new() { ServiceProvider = sp, IsProxyCommand = DevProxyCommand.IsRootCommand }, CancellationToken.None).Wait(); + plugin.InitializeAsync(new() { ServiceProvider = serviceProvider, IsProxyCommand = DevProxyCommand.IsRootCommand }, CancellationToken.None).Wait(); #pragma warning restore VSTHRD002 - return plugin; - }); - logger.LogDebug("Registered plugin {PluginName}.", pluginRef.Name); + } + catch (Exception ex) + { + var docsLink = DocsLinks.ForPlugin(plugin.GetType()); + if (docsLink is not null) + { + var message = (ex is AggregateException { InnerException: { } inner } ? inner.Message : ex.Message).TrimEnd('.'); + logger.LogError("Failed to initialize plugin {PluginName}: {ErrorMessage}. Docs: {DocsUrl}", plugin.Name, message, docsLink); + } + throw; + } } ///