Skip to content
Merged
183 changes: 183 additions & 0 deletions src/System.CommandLine.Hosting.Tests/HostingHandlerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using System.CommandLine.Binding;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.IO;
using System.CommandLine.Parsing;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Xunit;


namespace System.CommandLine.Hosting.Tests
{
public static class HostingHandlerTest
{

[Fact]
public static async Task Constructor_Injection_Injects_Service()
{
var service = new MyService();

var parser = new CommandLineBuilder(
new MyCommand()
)
.UseHost((builder) => {
builder.ConfigureServices(services =>
{
services.AddTransient(x => service);
})
.UseCommandHandler<MyCommand, MyCommand.MyHandler>();
})
.Build();

var result = await parser.InvokeAsync(new string[] { "--int-option", "54"});

service.Value.Should().Be(54);
}

[Fact]
public static async Task Parameter_is_available_in_property()
{
var parser = new CommandLineBuilder(new MyCommand())
.UseHost(host =>
{
host.ConfigureServices(services =>
{
services.AddTransient<MyService>();
})
.UseCommandHandler<MyCommand, MyCommand.MyHandler>();
})
.Build();

var result = await parser.InvokeAsync(new string[] { "--int-option", "54"});

result.Should().Be(54);
}

[Fact]
public static async Task Can_have_diferent_handlers_based_on_command()
{
var root = new RootCommand();

root.AddCommand(new MyCommand());
root.AddCommand(new MyOtherCommand());
var parser = new CommandLineBuilder(root)
.UseHost(host =>
{
host.ConfigureServices(services =>
{
services.AddTransient<MyService>(_ => new MyService()
{
Action = () => 100
});
})
.UseCommandHandler<MyCommand, MyCommand.MyHandler>()
.UseCommandHandler<MyOtherCommand, MyOtherCommand.MyHandler>();
})
.Build();

var result = await parser.InvokeAsync(new string[] { "mycommand", "--int-option", "54" });

result.Should().Be(54);

result = await parser.InvokeAsync(new string[] { "myothercommand", "--int-option", "54" });

result.Should().Be(100);
}

[Fact]
public static async Task Can_bind_to_arguments_via_injection()
{
var service = new MyService();
var cmd = new RootCommand();
cmd.AddCommand(new MyOtherCommand());
var parser = new CommandLineBuilder(cmd)
.UseHost(host =>
{
host.ConfigureServices(services =>
{
services.AddSingleton<MyService>(service);
})
.UseCommandHandler<MyOtherCommand, MyOtherCommand.MyHandler>();
})
.Build();

var result = await parser.InvokeAsync(new string[] { "myothercommand", "TEST" });

service.StringValue.Should().Be("TEST");
}

public class MyCommand : Command
{
public MyCommand() : base(name: "mycommand")
{
AddOption(new Option<int>("--int-option")); // or nameof(Handler.IntOption).ToKebabCase() if you don't like the string literal
}

public class MyHandler : ICommandHandler
{
private readonly MyService service;

public MyHandler(MyService service)
{
this.service = service;
}

public int IntOption { get; set; } // bound from option
public IConsole Console { get; set; } // bound from DI

public Task<int> InvokeAsync(InvocationContext context)
{
service.Value = IntOption;
return Task.FromResult(IntOption);
}
}
}

public class MyOtherCommand : Command
{
public MyOtherCommand() : base(name: "myothercommand")
{
AddOption(new Option<int>("--int-option")); // or nameof(Handler.IntOption).ToKebabCase() if you don't like the string literal
AddArgument(new Argument<string>("One"));
}

public class MyHandler : ICommandHandler
{
private readonly MyService service;

public MyHandler(MyService service)
{
this.service = service;
}

public int IntOption { get; set; } // bound from option
public IConsole Console { get; set; } // bound from DI

public string One { get; set; }

public Task<int> InvokeAsync(InvocationContext context)
{
service.Value = IntOption;
service.StringValue = One;
return Task.FromResult(service.Action?.Invoke() ?? 0);
}
}
}

public class MyService
{
public Func<int> Action { get; set; }

public int Value { get; set; }

public string StringValue { get; set; }
}
}
}
25 changes: 25 additions & 0 deletions src/System.CommandLine.Hosting.Tests/HostingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,5 +232,30 @@ private class MyOptions
{
public int MyArgument { get; set; }
}

private class MyService
{
public int SomeValue { get; set; }
}

private class CommandExecuter
{
public CommandExecuter(MyService service)
{
Service = service;
}

public MyService Service { get; }

public void Execute(int myArgument)
{
Service.SomeValue = myArgument;
}

public void SubCommand(int myArgument)
{
Service.SomeValue = myArgument;
}
}
}
}
35 changes: 35 additions & 0 deletions src/System.CommandLine.Hosting/HostingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,40 @@ public static OptionsBuilder<TOptions> BindCommandLine<TOptions>(
modelBinder.UpdateInstance(opts, bindingContext);
});
}

public static IHostBuilder UseCommandHandler<TCommand, THandler>(this IHostBuilder builder)
where TCommand : Command
where THandler : ICommandHandler
{
return builder.UseCommandHandler(typeof(TCommand), typeof(THandler));
}

public static IHostBuilder UseCommandHandler(this IHostBuilder builder, Type commandType, Type handlerType)
{
if (!typeof(Command).IsAssignableFrom(commandType))
{
throw new ArgumentException($"{nameof(commandType)} must be a type of {nameof(Command)}", nameof(handlerType));
}

if (!typeof(ICommandHandler).IsAssignableFrom(handlerType))
{
throw new ArgumentException($"{nameof(handlerType)} must implement {nameof(ICommandHandler)}", nameof(handlerType));
}

if (builder.Properties[typeof(InvocationContext)] is InvocationContext invocation
&& invocation.ParseResult.CommandResult.Command is Command command
&& command.GetType() == commandType)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the fact that this is an exact type match, as opposed to allowing for matching on subtypes, will be less intuitive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unsure if it risks causing more confusion if a command and a more specific command is registered, then the handler for the command might end up matching with the more specific command if i am understanding the scenario correctly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like it might also be redundant with the following line.

Is there a way to ask the container if it can create an instance of handlerType, so we don't implement logic that's inconsistent with the container configuration?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you thinking testing if the container already has the handler type registered? Or verifying we are not parsed something the container cannot possibly new up?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, the container might be configured to map ISomeCommand to MyCommand. The current code would block that from working because of the command.GetType() == commandType check. So in effect we're reimplementing a type matching check event though the resolved handler might work just fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that the check is there to ensure that the command is the one that the caller configured to be handeled by that commandHandler.

Is it even possible to have the command as something not inheriting from Command like an interface?

{
invocation.BindingContext.AddService(handlerType, c => c.GetService<IHost>().Services.GetService(handlerType));
builder.ConfigureServices(services =>
{
services.AddTransient(handlerType);
});

command.Handler = CommandHandler.Create(handlerType.GetMethod(nameof(ICommandHandler.InvokeAsync)));
}

return builder;
}
}
}
10 changes: 8 additions & 2 deletions src/System.CommandLine/Invocation/ModelBindingCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ public async Task<int> InvokeAsync(InvocationContext context)
object result;
if (_handlerDelegate is null)
{
var invocationTarget = _invocationTarget ??
_invocationTargetBinder?.CreateInstance(bindingContext);
var invocationTarget = _invocationTarget ??
bindingContext.ServiceProvider.GetService(_handlerMethodInfo!.DeclaringType);
if(invocationTarget is { })
{
_invocationTargetBinder?.UpdateInstance(invocationTarget, bindingContext);
}

invocationTarget ??= _invocationTargetBinder?.CreateInstance(bindingContext);
result = _handlerMethodInfo!.Invoke(invocationTarget, invocationArguments);
}
else
Expand Down