diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 2d27c1ca..f811dcfb 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -54,7 +54,8 @@ function tableOfContents() { {text: "Environment Checks", link: '/guide/host/environment'}, {text: "Writing Extension Commands", link: '/guide/host/extensions'}, {text: "The \"describe\" command", link: '/guide/host/describe'}, - {text: "Stateful Resources", link: '/guide/host/resources'} + {text: "Stateful Resources", link: '/guide/host/resources'}, + {text: "Using IoC Services", link: '/guide/host/ioc.md'} ] }, {text: "Bootstrapping with CommandExecutor", link: '/guide/bootstrapping'}, diff --git a/docs/guide/host/integration_with_i_host.md b/docs/guide/host/integration_with_i_host.md index 93303d80..9444f54d 100644 --- a/docs/guide/host/integration_with_i_host.md +++ b/docs/guide/host/integration_with_i_host.md @@ -113,3 +113,4 @@ There are just a couple things to note: ## Combining Serilog with Oakton If you're having any issues with Serilog logging while using Oakton, please see this [StackOverflow issue](https://stackoverflow.com/questions/55422528/logging-with-serilog-net-core-not-outputting). + diff --git a/docs/guide/host/ioc.md b/docs/guide/host/ioc.md new file mode 100644 index 00000000..afb7a4aa --- /dev/null +++ b/docs/guide/host/ioc.md @@ -0,0 +1,23 @@ +# Using IoC Services + +Very frequently, folks have wanted to either use services from their IoC/DI container for their application, or to +have Oakton resolve the command objects from the application's DI container. New in Oakton 6.2 is that very ability. + +## Injecting Services into Commands + +If you are using [Oakton's IHost integration](/oakton/guide/host/integration_with_i_host), you can write commands that +use IoC services by simply decorating a publicly settable property on your Oakton command classes with the +new `[InjectService]` attribute. + +First though, just to make sure you're clear about when and when this isn't applicable, this applies to Oakton used +from an `IHostBuilder` or `ApplicationBuilder` like so: + +snippet: sample_using_ihost_activation + +Then you can decorate your command types something like this: + +snippet: sample_MyDbCommand + +## Using IoC Command Creators + + diff --git a/src/Oakton/CommandFactory.cs b/src/Oakton/CommandFactory.cs index 5d5aaaa2..5dffd767 100644 --- a/src/Oakton/CommandFactory.cs +++ b/src/Oakton/CommandFactory.cs @@ -92,7 +92,6 @@ public CommandRun BuildRun(IEnumerable args) return buildRun(queue, CommandNameFor(DefaultCommand)); } - var firstArg = queue.Peek().ToLowerInvariant(); if (_helpCommands.Contains(firstArg)) @@ -209,7 +208,6 @@ private CommandRun buildRun(Queue queue, string commandName) var command = Build(commandName); input ??= command.Usages.BuildInput(queue, _commandCreator); - var run = new CommandRun { Command = command, diff --git a/src/Oakton/CommandLineHostingExtensions.cs b/src/Oakton/CommandLineHostingExtensions.cs index eca02885..762bf7ee 100644 --- a/src/Oakton/CommandLineHostingExtensions.cs +++ b/src/Oakton/CommandLineHostingExtensions.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using JasperFx.Core.Reflection; namespace Oakton; @@ -140,13 +141,25 @@ private static CommandExecutor buildExecutor(IHostBuilder source, Assembly? appl { factory.ApplyFactoryDefaults(applicationAssembly); - factory.ConfigureRun = cmd => + factory.ConfigureRun = commandRun => { - if (cmd.Input is IHostBuilderInput i) + if (commandRun.Input is IHostBuilderInput i) { factory.ApplyExtensions(source); i.HostBuilder = source; } + else + { + var props = commandRun.Command.GetType().GetProperties().Where(x => x.HasAttribute()) + .ToArray(); + + if (props.Any()) + { + commandRun.Command = new HostWrapperCommand(commandRun.Command, source.Build, props); + } + } + + }; }); diff --git a/src/Oakton/HostWrapperCommand.cs b/src/Oakton/HostWrapperCommand.cs new file mode 100644 index 00000000..abbaaa59 --- /dev/null +++ b/src/Oakton/HostWrapperCommand.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Oakton.Help; + +namespace Oakton; + +internal class HostWrapperCommand : IOaktonCommand +{ + private readonly IOaktonCommand _inner; + private readonly Func _hostSource; + private readonly PropertyInfo[] _props; + + public HostWrapperCommand(IOaktonCommand inner, Func hostSource, PropertyInfo[] props) + { + _inner = inner; + _hostSource = hostSource; + _props = props; + } + + public Type InputType => _inner.InputType; + public UsageGraph Usages => _inner.Usages; + public async Task Execute(object input) + { + using var host = _hostSource(); + using var scope = host.Services.CreateScope(); + foreach (var prop in _props) + { + var serviceType = prop.PropertyType; + var service = scope.ServiceProvider.GetRequiredService(serviceType); + prop.SetValue(_inner, service); + } + + return await _inner.Execute(input); + } +} \ No newline at end of file diff --git a/src/Oakton/InjectServiceAttribute.cs b/src/Oakton/InjectServiceAttribute.cs new file mode 100644 index 00000000..b9ebc4b9 --- /dev/null +++ b/src/Oakton/InjectServiceAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Oakton; + +/// +/// Decorate Oakton commands that are being called by +/// +[AttributeUsage(AttributeTargets.Property)] +public class InjectServiceAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/Tests/Tests.csproj.DotSettings b/src/Tests/Tests.csproj.DotSettings deleted file mode 100644 index 4887f947..00000000 --- a/src/Tests/Tests.csproj.DotSettings +++ /dev/null @@ -1,2 +0,0 @@ - - CSharp100 \ No newline at end of file diff --git a/src/Tests/using_injected_services.cs b/src/Tests/using_injected_services.cs new file mode 100644 index 00000000..28804409 --- /dev/null +++ b/src/Tests/using_injected_services.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Oakton; +using Oakton.Help; +using Shouldly; +using Xunit; + +[assembly: OaktonCommandAssembly] + +namespace Tests; + +public class using_injected_services +{ + [Fact] + public async Task can_use_injected_services() + { + var success = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddScoped(); + services.AddScoped(); + }) + .RunOaktonCommands(new string[] { "injected", "Bob Marley" }); + + success.ShouldBe(0); + + MyService.WasCalled.ShouldBeTrue(); + MyService.Name.ShouldBe("Bob Marley"); + MyService.WasDisposed.ShouldBeTrue(); + + OtherService.WasCalled.ShouldBeTrue(); + OtherService.Name.ShouldBe("Bob Marley"); + OtherService.WasDisposed.ShouldBeTrue(); + + } +} + +public class InjectedInput +{ + public string Name { get; set; } +} + +[Description("Injected command", Name = "injected")] +public class InjectedCommand : OaktonCommand +{ + [InjectService] + public MyService One { get; set; } + + [InjectService] + public OtherService Two { get; set; } + + public override bool Execute(InjectedInput input) + { + One.DoStuff(input.Name); + Two.DoStuff(input.Name); + + return true; + } +} + +public class MyService : IDisposable +{ + public static bool WasCalled; + public static string Name; + + public static bool WasDisposed; + + public void DoStuff(string name) + { + WasCalled = true; + Name = name; + } + + public void Dispose() + { + WasDisposed = true; + } +} + +public class OtherService : IDisposable +{ + public static bool WasCalled; + public static string Name; + + public static bool WasDisposed; + + public void DoStuff(string name) + { + WasCalled = true; + Name = name; + } + + public void Dispose() + { + WasDisposed = true; + } +} + +public class MyDbContext{} + +public class MyInput +{ + +} + +#region sample_MyDbCommand + +public class MyDbCommand : OaktonAsyncCommand +{ + [InjectService] + public MyDbContext DbContext { get; set; } + + public override Task Execute(MyInput input) + { + // do stuff with DbContext from up above + return Task.FromResult(true); + } +} + +#endregion \ No newline at end of file diff --git a/src/WorkerService/Program.cs b/src/WorkerService/Program.cs index 2fee3d81..ccec9da0 100644 --- a/src/WorkerService/Program.cs +++ b/src/WorkerService/Program.cs @@ -7,6 +7,8 @@ namespace WorkerService { + #region sample_using_ihost_activation + public class Program { public static Task Main(string[] args) @@ -16,7 +18,10 @@ public static Task Main(string[] args) } public static IHostBuilder CreateHostBuilder(string[] args) => + // This is a little old-fashioned, but still valid .NET core code: Host.CreateDefaultBuilder(args) .ConfigureServices((hostContext, services) => { services.AddHostedService(); }); } + + #endregion } \ No newline at end of file