Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion DevProxy.Abstractions/Plugins/BasePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down
43 changes: 43 additions & 0 deletions DevProxy.Abstractions/Utils/DocsLinks.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Builds stable documentation shortlinks (https://go.devproxy.net?go=&lt;code&gt;).
/// The shortlink service redirects codes to the actual documentation pages,
/// so the CLI never hard-codes documentation paths.
/// </summary>
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);
}

/// <summary>
/// Returns the documentation link for a plugin, or null if the plugin
/// isn't shipped with Dev Proxy and has no canonical documentation page.
/// </summary>
public static string? ForPlugin(Type pluginType)
{
ArgumentNullException.ThrowIfNull(pluginType);
return IsBuiltInPluginAssembly(pluginType.Assembly) ? Get(pluginType.Name) : null;
}
}
80 changes: 80 additions & 0 deletions DevProxy.Tests/HelpDocsTests.cs
Original file line number Diff line number Diff line change
@@ -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)));
}
}
5 changes: 4 additions & 1 deletion DevProxy/Commands/ApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
}
}
}
Expand Down
7 changes: 4 additions & 3 deletions DevProxy/Commands/DevProxyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -638,10 +638,11 @@ private void ConfigureCommand()
ActivatorUtilities.CreateInstance<StopCommand>(_serviceProvider),
ActivatorUtilities.CreateInstance<LogsCommand>(_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)
{
Expand Down
20 changes: 15 additions & 5 deletions DevProxy/Commands/DevProxyHelpAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <config-file> to load a specific configuration.");
Expand All @@ -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();
}
}
57 changes: 57 additions & 0 deletions DevProxy/Commands/HelpDocs.cs
Original file line number Diff line number Diff line change
@@ -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<Command, StrongBox<string?>> _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<string>();
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)}");
}
}
53 changes: 41 additions & 12 deletions DevProxy/Plugins/PluginServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
Expand All @@ -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;
}
}

/// <summary>
Expand Down
Loading