Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DI Hosted commands #93

Merged
merged 2 commits into from
Sep 4, 2024
Merged
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
58 changes: 30 additions & 28 deletions src/Oakton/CommandFactory.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using JasperFx.Core;
using JasperFx.Core;
using JasperFx.Core.Reflection;
using JasperFx.Core.TypeScanning;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Oakton.Help;
using Oakton.Parsing;
using Spectre.Console;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;

namespace Oakton;

Expand Down Expand Up @@ -141,37 +142,38 @@ public IEnumerable<IOaktonCommand> BuildAllCommands()
return _commandTypes.Select(x => _commandCreator.CreateCommand(x));
}

public void ApplyExtensions(IHostBuilder builder)
public void ApplyExtensions(IServiceCollection services)
{
if (_extensionTypes.Any())
try
{
try
foreach (var extensionType in _extensionTypes)
{
builder.ConfigureServices(services =>
{
foreach (var extensionType in _extensionTypes)
{
var extension = Activator.CreateInstance(extensionType) as IServiceRegistrations;
extension?.Configure(services);
}
});

_hasAppliedExtensions = true;
var extension = Activator.CreateInstance(extensionType) as IServiceRegistrations;
extension?.Configure(services);
}
catch (Exception)

_hasAppliedExtensions = true;
}
catch (Exception)
{
// Swallow the error
if (_hasAppliedExtensions)
{
// Swallow the error
if (_hasAppliedExtensions)
{
return;
}

AnsiConsole.MarkupLine(
$"[red]Unable to apply Oakton extensions. Try adding IHostBuilder.{nameof(CommandLineHostingExtensions.ApplyOaktonExtensions)}(); to your bootstrapping code to apply Oakton extension loading[/]");
return;
}

AnsiConsole.MarkupLine(
$"[red]Unable to apply Oakton extensions. Try adding IHostBuilder.{nameof(CommandLineHostingExtensions.ApplyOaktonExtensions)}(); to your bootstrapping code to apply Oakton extension loading[/]");
}
}

public void ApplyExtensions(IHostBuilder builder)
{
if (_extensionTypes.Any())
{
builder.ConfigureServices(ApplyExtensions);
}
}

public IEnumerable<Type> AllCommandTypes()
{
Expand Down
44 changes: 27 additions & 17 deletions src/Oakton/CommandLineHostingExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#nullable enable

using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using JasperFx.Core;
using Microsoft.Extensions.Hosting;
using Oakton.Commands;
using Oakton.Internal;
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;

namespace Oakton;

Expand Down Expand Up @@ -86,9 +87,7 @@ public static int RunOaktonCommandsSynchronously(this IHost host, string[] args,
.GetResult();
}


private static Task<int> execute(IHostBuilder runtimeSource, Assembly? applicationAssembly, string[] args,
string? optionsFile)
internal static string[] ApplyArgumentDefaults(this string[] args, string? optionsFile)
{
// Workaround for IISExpress / VS2019 erroneously putting crap arguments
args = args.FilterLauncherArgs();
Expand All @@ -99,11 +98,30 @@ private static Task<int> execute(IHostBuilder runtimeSource, Assembly? applicati
args = CommandExecutor.ReadOptions(optionsFile).Concat(args).ToArray();
}

if (args == null || args.Length == 0 || args[0].StartsWith("-"))
if (args == null || args.Length == 0 || args[0].StartsWith('-'))
{
args = new[] { "run" }.Concat(args ?? Array.Empty<string>()).ToArray();
}

return args;
}

internal static void ApplyFactoryDefaults(this CommandFactory factory, Assembly? applicationAssembly)
{
factory.RegisterCommands(typeof(RunCommand).GetTypeInfo().Assembly);

if (applicationAssembly != null)
{
args = new[] { "run" }.Concat(args ?? new string[0]).ToArray();
factory.RegisterCommands(applicationAssembly);
}

factory.RegisterCommandsFromExtensionAssemblies();
}

private static Task<int> execute(IHostBuilder runtimeSource, Assembly? applicationAssembly, string[] args,
string? optionsFile)
{
args = args.ApplyArgumentDefaults(optionsFile);

var commandExecutor = buildExecutor(runtimeSource, applicationAssembly);
return commandExecutor.ExecuteAsync(args);
Expand All @@ -120,15 +138,7 @@ private static CommandExecutor buildExecutor(IHostBuilder source, Assembly? appl

return CommandExecutor.For(factory =>
{
factory.RegisterCommands(typeof(RunCommand).GetTypeInfo().Assembly);
if (applicationAssembly != null)
{
factory.RegisterCommands(applicationAssembly);
}

// This method will direct the CommandFactory to go look for extension
// assemblies with Oakton commands
factory.RegisterCommandsFromExtensionAssemblies();
factory.ApplyFactoryDefaults(applicationAssembly);

factory.ConfigureRun = cmd =>
{
Expand Down
24 changes: 24 additions & 0 deletions src/Oakton/DependencyInjectionCommandCreator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using System;

namespace Oakton;

internal class DependencyInjectionCommandCreator : ICommandCreator
{
private readonly IServiceProvider _serviceProvider;
public DependencyInjectionCommandCreator(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

public IOaktonCommand CreateCommand(Type commandType)
{
var scope = _serviceProvider.CreateScope();
return (IOaktonCommand)scope.ServiceProvider.GetRequiredService(commandType);
}

public object CreateModel(Type modelType)
{
return Activator.CreateInstance(modelType)!;
}
}
102 changes: 102 additions & 0 deletions src/Oakton/HostedCommandExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#nullable enable

using JasperFx.Core.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using System;
using System.Reflection;
using System.Threading.Tasks;

namespace Oakton;

public static class HostedCommandExtensions
{
/// <summary>
/// Register Oakton commands and services with the application's service collection.
/// </summary>
/// <param name="services"></param>
/// <param name="factoryBuilder"></param>
public static void AddOakton(this IServiceCollection services, Action<CommandFactory>? factoryBuilder = null)
{
var registrationFactory = new CommandFactory();
registrationFactory.ApplyFactoryDefaults(factoryBuilder);
registrationFactory.ApplyExtensions(services);

var commands = registrationFactory.AllCommandTypes();
foreach (var commandType in commands)
{
if (commandType.IsConcrete() && commandType.CanBeCastTo<IOaktonCommand>())
{
services.AddScoped(commandType);
}
}

services.TryAddSingleton<ICommandCreator, DependencyInjectionCommandCreator>();

services.TryAddSingleton<ICommandFactory>((ctx) =>
{
var creator = ctx.GetRequiredService<ICommandCreator>();
var factory = new CommandFactory(creator);
factory.ApplyFactoryDefaults(factoryBuilder);
return factory;
});

services.TryAddSingleton<CommandExecutor>();
}

/// <summary>
/// Execute the extended Oakton command line support for your configured IHost.
/// This method would be called within the Task&lt;int&gt; Program.Main(string[] args) method
/// of your AspNetCore application. This usage is appropriate for WebApplication bootstrapping
/// </summary>
/// <param name="host">An already built IHost</param>
/// <param name="args"></param>
/// <param name="builder">Optionally configure additional command options</param>
/// <returns></returns>
public static int RunHostedOaktonCommands(this IHost host, string[] args, Action<HostedCommandOptions>? builder = null)
{
return RunHostedOaktonCommandsAsync(host, args, builder).GetAwaiter().GetResult();
}

/// <summary>
/// Execute the extended Oakton command line support for your configured IHost.
/// This method would be called within the Task&lt;int&gt; Program.Main(string[] args) method
/// of your AspNetCore application. This usage is appropriate for WebApplication bootstrapping.
/// </summary>
/// <param name="host">An already built IHost</param>
/// <param name="args"></param>
/// <param name="builder">Optionally configure additional command options</param>
/// <returns></returns>
public static Task<int> RunHostedOaktonCommandsAsync(this IHost host, string[] args, Action<HostedCommandOptions>? builder = null)
{
var options = new HostedCommandOptions();
builder?.Invoke(options);

args = args.ApplyArgumentDefaults(options.OptionsFile);

var executor = host.Services.GetRequiredService<CommandExecutor>();

if (executor.Factory is CommandFactory factory)
{
var originalConfigureRun = factory.ConfigureRun;
factory.ConfigureRun = cmd =>
{
if (cmd.Input is IHostBuilderInput i)
{
i.HostBuilder = new PreBuiltHostBuilder(host);
}

originalConfigureRun?.Invoke(cmd);
};
}

return executor.ExecuteAsync(args);
}

private static void ApplyFactoryDefaults(this CommandFactory factory, Action<CommandFactory>? builder = null)
{
factory.ApplyFactoryDefaults(Assembly.GetEntryAssembly());
builder?.Invoke(factory);
}
}
6 changes: 6 additions & 0 deletions src/Oakton/HostedCommandOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Oakton;

public class HostedCommandOptions
{
public string OptionsFile { get; }
}
49 changes: 49 additions & 0 deletions src/Tests/HostedCommandsTester.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Oakton;
using Xunit;

namespace Tests;

public class HostedCommandsTester
{
[Fact]
public void CanInjectServicesIntoCommands()
{
var builder = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<TestDependency>();
services.AddOakton(factory =>
{
factory.RegisterCommand<TestDICommand>();
});
});

var app = builder.Build();

app.RunHostedOaktonCommands(new string[] { "TestDI" });

Assert.Equal(1, TestDICommand.Value);
}

public class TestInput
{
}

public record TestDependency(int Value = 1);

public class TestDICommand : OaktonCommand<TestInput>
{
public static int Value { get; set; } = 0;
public TestDICommand(TestDependency dep)
{
Value = dep.Value;
}

public override bool Execute(TestInput input)
{
return true;
}
}
}
Loading