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;
+ }
}
///