From 0b00a8dbfbf0674cbfa72644e10f71a05c05b491 Mon Sep 17 00:00:00 2001 From: Trent Gardner Date: Mon, 19 Aug 2024 21:41:26 +0930 Subject: [PATCH 1/2] Add DI Hosted commands --- src/Oakton/CommandFactory.cs | 30 +++--- src/Oakton/CommandLineHostingExtensions.cs | 44 +++++--- .../DependencyInjectionCommandCreator.cs | 24 +++++ src/Oakton/HostedCommandExtensions.cs | 102 ++++++++++++++++++ src/Oakton/HostedCommandOptions.cs | 6 ++ src/Tests/HostedCommandsTester.cs | 49 +++++++++ 6 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 src/Oakton/DependencyInjectionCommandCreator.cs create mode 100644 src/Oakton/HostedCommandExtensions.cs create mode 100644 src/Oakton/HostedCommandOptions.cs create mode 100644 src/Tests/HostedCommandsTester.cs diff --git a/src/Oakton/CommandFactory.cs b/src/Oakton/CommandFactory.cs index 35e65478..81a90153 100644 --- a/src/Oakton/CommandFactory.cs +++ b/src/Oakton/CommandFactory.cs @@ -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; @@ -141,20 +142,17 @@ public IEnumerable BuildAllCommands() return _commandTypes.Select(x => _commandCreator.CreateCommand(x)); } - public void ApplyExtensions(IHostBuilder builder) + public void ApplyExtensions(IServiceCollection services) { if (_extensionTypes.Any()) { try { - builder.ConfigureServices(services => + foreach (var extensionType in _extensionTypes) { - foreach (var extensionType in _extensionTypes) - { - var extension = Activator.CreateInstance(extensionType) as IServiceRegistrations; - extension?.Configure(services); - } - }); + var extension = Activator.CreateInstance(extensionType) as IServiceRegistrations; + extension?.Configure(services); + } _hasAppliedExtensions = true; } @@ -172,6 +170,10 @@ public void ApplyExtensions(IHostBuilder builder) } } + public void ApplyExtensions(IHostBuilder builder) + { + builder.ConfigureServices(ApplyExtensions); + } public IEnumerable AllCommandTypes() { diff --git a/src/Oakton/CommandLineHostingExtensions.cs b/src/Oakton/CommandLineHostingExtensions.cs index 988136d9..eca02885 100644 --- a/src/Oakton/CommandLineHostingExtensions.cs +++ b/src/Oakton/CommandLineHostingExtensions.cs @@ -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; @@ -86,9 +87,7 @@ public static int RunOaktonCommandsSynchronously(this IHost host, string[] args, .GetResult(); } - - private static Task 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(); @@ -99,11 +98,30 @@ private static Task 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()).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 execute(IHostBuilder runtimeSource, Assembly? applicationAssembly, string[] args, + string? optionsFile) + { + args = args.ApplyArgumentDefaults(optionsFile); var commandExecutor = buildExecutor(runtimeSource, applicationAssembly); return commandExecutor.ExecuteAsync(args); @@ -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 => { diff --git a/src/Oakton/DependencyInjectionCommandCreator.cs b/src/Oakton/DependencyInjectionCommandCreator.cs new file mode 100644 index 00000000..69dde191 --- /dev/null +++ b/src/Oakton/DependencyInjectionCommandCreator.cs @@ -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)!; + } +} diff --git a/src/Oakton/HostedCommandExtensions.cs b/src/Oakton/HostedCommandExtensions.cs new file mode 100644 index 00000000..f54cdd37 --- /dev/null +++ b/src/Oakton/HostedCommandExtensions.cs @@ -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 +{ + /// + /// Register Oakton commands and services with the application's service collection. + /// + /// + /// + public static void AddOakton(this IServiceCollection services, Action? 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()) + { + services.AddScoped(commandType); + } + } + + services.TryAddSingleton(); + + services.TryAddSingleton((ctx) => + { + var creator = ctx.GetRequiredService(); + var factory = new CommandFactory(creator); + factory.ApplyFactoryDefaults(factoryBuilder); + return factory; + }); + + services.TryAddSingleton(); + } + + /// + /// Execute the extended Oakton command line support for your configured IHost. + /// This method would be called within the Task<int> Program.Main(string[] args) method + /// of your AspNetCore application. This usage is appropriate for WebApplication bootstrapping + /// + /// An already built IHost + /// + /// Optionally configure additional command options + /// + public static int RunHostedOaktonCommands(this IHost host, string[] args, Action? builder = null) + { + return RunHostedOaktonCommandsAsync(host, args, builder).GetAwaiter().GetResult(); + } + + /// + /// Execute the extended Oakton command line support for your configured IHost. + /// This method would be called within the Task<int> Program.Main(string[] args) method + /// of your AspNetCore application. This usage is appropriate for WebApplication bootstrapping. + /// + /// An already built IHost + /// + /// Optionally configure additional command options + /// + public static Task RunHostedOaktonCommandsAsync(this IHost host, string[] args, Action? builder = null) + { + var options = new HostedCommandOptions(); + builder?.Invoke(options); + + args = args.ApplyArgumentDefaults(options.OptionsFile); + + var executor = host.Services.GetRequiredService(); + + 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? builder = null) + { + factory.ApplyFactoryDefaults(Assembly.GetEntryAssembly()); + builder?.Invoke(factory); + } +} diff --git a/src/Oakton/HostedCommandOptions.cs b/src/Oakton/HostedCommandOptions.cs new file mode 100644 index 00000000..b3bc3083 --- /dev/null +++ b/src/Oakton/HostedCommandOptions.cs @@ -0,0 +1,6 @@ +namespace Oakton; + +public class HostedCommandOptions +{ + public string OptionsFile { get; } +} diff --git a/src/Tests/HostedCommandsTester.cs b/src/Tests/HostedCommandsTester.cs new file mode 100644 index 00000000..3afc2cfb --- /dev/null +++ b/src/Tests/HostedCommandsTester.cs @@ -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(); + services.AddOakton(factory => + { + factory.RegisterCommand(); + }); + }); + + 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 + { + public static int Value { get; set; } = 0; + public TestDICommand(TestDependency dep) + { + Value = dep.Value; + } + + public override bool Execute(TestInput input) + { + return true; + } + } +} From 1b41ee276a00f954bf469a5a92ad8903ad1c4308 Mon Sep 17 00:00:00 2001 From: Trent Gardner Date: Mon, 26 Aug 2024 05:56:21 +0930 Subject: [PATCH 2/2] refactor(CommandFactory.cs): simplify ApplyExtensions method by removing nested try-catch and redundant checks --- src/Oakton/CommandFactory.cs | 38 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Oakton/CommandFactory.cs b/src/Oakton/CommandFactory.cs index 81a90153..5d5aaaa2 100644 --- a/src/Oakton/CommandFactory.cs +++ b/src/Oakton/CommandFactory.cs @@ -144,35 +144,35 @@ public IEnumerable BuildAllCommands() public void ApplyExtensions(IServiceCollection services) { - if (_extensionTypes.Any()) + try { - try + foreach (var extensionType in _extensionTypes) { - 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) { - builder.ConfigureServices(ApplyExtensions); + if (_extensionTypes.Any()) + { + builder.ConfigureServices(ApplyExtensions); + } } public IEnumerable AllCommandTypes()