From b5f82ac2956fa4b9a0706e3ffc9f30ad4ee5a9a5 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 1 Jun 2025 07:27:38 -0500 Subject: [PATCH] Ability to recognize and utilize keyed services through [FromKeyedService] decorations throughout Wolverine --- .../using_keyed_services.cs | 20 +++ src/Http/WolverineWebApi/Program.cs | 3 + .../Acceptance/using_with_keyed_services.cs | 161 ++++++++++++++++++ src/Testing/CoreTests/CoreTests.csproj | 2 +- src/Wolverine/Codegen/ConstructorPlan.cs | 48 +++++- src/Wolverine/Codegen/InjectedSingleton.cs | 44 ++++- .../ServiceCollectionServerVariableSource.cs | 29 +++- src/Wolverine/Codegen/ServiceFamily.cs | 30 +++- src/Wolverine/Codegen/ServiceVariables.cs | 6 + src/Wolverine/Runtime/ServiceContainer.cs | 31 +++- src/Wolverine/Wolverine.csproj | 2 +- 11 files changed, 351 insertions(+), 25 deletions(-) create mode 100644 src/Http/Wolverine.Http.Tests/using_keyed_services.cs create mode 100644 src/Testing/CoreTests/Acceptance/using_with_keyed_services.cs diff --git a/src/Http/Wolverine.Http.Tests/using_keyed_services.cs b/src/Http/Wolverine.Http.Tests/using_keyed_services.cs new file mode 100644 index 000000000..55d58f119 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/using_keyed_services.cs @@ -0,0 +1,20 @@ +namespace Wolverine.Http.Tests; + +public class using_keyed_services : IntegrationContext +{ + public using_keyed_services(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task using_singleton_service() + { + await Scenario(x => x.Get.Url("/thing/red")); + } + + [Fact] + public async Task using_scoped_service() + { + await Scenario(x => x.Get.Url("/thing/blue")); + } +} \ No newline at end of file diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index 72cc7f4f7..f08896ded 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -63,6 +63,9 @@ builder.Services.AddDbContextWithWolverineIntegration( x => x.UseNpgsql(Servers.PostgresConnectionString)); +builder.Services.AddKeyedSingleton("Red"); +builder.Services.AddKeyedScoped("Blue"); +builder.Services.AddKeyedTransient("Green"); builder.Services.AddMarten(opts => { diff --git a/src/Testing/CoreTests/Acceptance/using_with_keyed_services.cs b/src/Testing/CoreTests/Acceptance/using_with_keyed_services.cs new file mode 100644 index 000000000..6339bda28 --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/using_with_keyed_services.cs @@ -0,0 +1,161 @@ +using System.ComponentModel.Design; +using System.Diagnostics; +using CoreTests.Codegen; +using Lamar.Microsoft.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Wolverine.Attributes; +using Xunit; + +namespace CoreTests.Acceptance; + +public class using_with_keyed_services : IAsyncLifetime +{ + private IHost _host; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(); + + opts.Services.AddKeyedSingleton("Red"); + opts.Services.AddKeyedScoped("Blue"); + opts.Services.AddKeyedSingleton("Green"); + + opts.Services.AddTransient(); + opts.Services.AddTransient(); + }).StartAsync(); + } + + public Task DisposeAsync() + { + return _host.StopAsync(); + } + + [Fact] + public async Task use_inside_of_deep_dependency_chain() + { + var container = _host.Services.GetRequiredService(); + var holder = container.QuickBuild(); + holder.ThingUser.Thing.ShouldBeOfType(); + + await _host.InvokeAsync(new UseThingHolder()); + } + + [Fact] + public async Task use_as_single_parameter_on_handler() + { + await _host.InvokeAsync(new UseThingDirectly()); + } + + [Fact] + public async Task use_as_singleton_parameter_on_handler() + { + await _host.InvokeAsync(new UseSingletonThingDirectly()); + } + + [Fact] + public async Task use_multiple_parameters_on_handler() + { + await _host.InvokeAsync(new UseMultipleThings()); + } +} + +public class using_with_keyed_services_and_lamar : IAsyncLifetime +{ + private IHost _host; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseLamar() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(); + + opts.Services.AddKeyedSingleton("Red"); + opts.Services.AddKeyedScoped("Blue"); + opts.Services.AddKeyedSingleton("Green"); + + opts.Services.AddTransient(); + opts.Services.AddTransient(); + }).StartAsync(); + } + + public Task DisposeAsync() + { + return _host.StopAsync(); + } + + [Fact] + public async Task use_inside_of_deep_dependency_chain() + { + var container = _host.Services.GetRequiredService(); + var holder = container.QuickBuild(); + holder.ThingUser.Thing.ShouldBeOfType(); + + await _host.InvokeAsync(new UseThingHolder()); + } + + [Fact] + public async Task use_as_single_parameter_on_handler() + { + await _host.InvokeAsync(new UseThingDirectly()); + } + + [Fact] + public async Task use_as_singleton_parameter_on_handler() + { + await _host.InvokeAsync(new UseSingletonThingDirectly()); + } + + [Fact] + public async Task use_multiple_parameters_on_handler() + { + await _host.InvokeAsync(new UseMultipleThings()); + } +} + +public interface IThing; +public class RedThing : IThing; +public class BlueThing : IThing; +public class GreenThing : IThing; + +public record ThingUser([FromKeyedServices("Red")] IThing Thing); + +public record ThingUserHolder(ThingUser ThingUser); + +public record UseThingDirectly; +public record UseSingletonThingDirectly; + +public record UseMultipleThings; + +public record UseThingHolder; + +[WolverineIgnore] +public class ThingHandler +{ + public static void Handle(UseThingHolder command, ThingUserHolder holder) + { + holder.ThingUser.Thing.ShouldBeOfType(); + } + + public static void Handle(UseThingDirectly command, [FromKeyedServices("Blue")] IThing thing) + { + thing.ShouldBeOfType(); + } + + public static void Handle(UseSingletonThingDirectly command, [FromKeyedServices("Red")] IThing thing) + { + thing.ShouldBeOfType(); + } + + public static void Handle(UseMultipleThings command, [FromKeyedServices("Green")] IThing green, + [FromKeyedServices("Red")] IThing red) + { + green.ShouldBeOfType(); + red.ShouldBeOfType(); + } +} \ No newline at end of file diff --git a/src/Testing/CoreTests/CoreTests.csproj b/src/Testing/CoreTests/CoreTests.csproj index ffe889503..b78b49b0b 100644 --- a/src/Testing/CoreTests/CoreTests.csproj +++ b/src/Testing/CoreTests/CoreTests.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/Wolverine/Codegen/ConstructorPlan.cs b/src/Wolverine/Codegen/ConstructorPlan.cs index e5d0308a9..051886541 100644 --- a/src/Wolverine/Codegen/ConstructorPlan.cs +++ b/src/Wolverine/Codegen/ConstructorPlan.cs @@ -47,10 +47,13 @@ public static bool TryBuildPlan(List trail, ServiceDescriptor } trail.Add(descriptor); + + var implementationType = descriptor.IsKeyedService ? descriptor.KeyedImplementationType : descriptor.ImplementationType; try { - var constructors = FindPublicConstructorCandidates(descriptor.ImplementationType); + + var constructors = FindPublicConstructorCandidates(implementationType); // If no public constructors, get out of here if (!constructors.Any()) @@ -65,13 +68,43 @@ public static bool TryBuildPlan(List trail, ServiceDescriptor return true; } + Func hasPlan = parameter => + { + if (parameter.TryGetAttribute(out var att)) + { + var descriptor = graph + .RegistrationsFor(parameter.ParameterType) + .Where(x => x.IsKeyedService) + .FirstOrDefault(x => x.ServiceKey.ToString() == att.Key.ToString()); + + if (descriptor == null) return false; + + return graph.PlanFor(descriptor, trail) is not InvalidPlan; + } + + return graph.FindDefault(parameter.ParameterType, trail) is not InvalidPlan; + }; + var constructor = constructors - .Where(x => x.GetParameters().All(p => graph.FindDefault(p.ParameterType, trail) is not InvalidPlan)) + .Where(x => x.GetParameters().All(x => hasPlan(x))) .MinBy(x => x.GetParameters().Length); if (constructor != null) { - var dependencies = constructor.GetParameters().Select(x => graph.FindDefault(x.ParameterType, trail)).ToArray(); + var dependencies = constructor.GetParameters().Select(x => + { + if (x.TryGetAttribute(out var att)) + { + var descriptor = graph + .RegistrationsFor(x.ParameterType) + .Where(x => x.IsKeyedService) + .FirstOrDefault(x => x.ServiceKey.ToString() == att.Key.ToString()); + + return graph.PlanFor(descriptor, trail); + } + + return graph.FindDefault(x.ParameterType, trail); + }).ToArray(); if (dependencies.OfType().Any() || dependencies.Any(x => x == null)) { @@ -99,10 +132,6 @@ public ConstructorPlan(ServiceDescriptor descriptor, ConstructorInfo constructor if (descriptor.Lifetime == ServiceLifetime.Singleton) throw new ArgumentOutOfRangeException(nameof(descriptor), $"Only {ServiceLifetime.Scoped} or {ServiceLifetime.Transient} lifecycles are valid"); -#if NET8_0_OR_GREATER - - if (descriptor.IsKeyedService) throw new ArgumentOutOfRangeException(nameof(descriptor), "Cannot yet support keyed services"); -#endif if (constructor.GetParameters().Length != dependencies.Length) { @@ -159,8 +188,9 @@ public override string WhyRequireServiceProvider(IMethodVariables method) public override Variable CreateVariable(ServiceVariables resolverVariables) { - var frame = new ConstructorFrame(Descriptor.ImplementationType, Constructor); - if (Descriptor.ImplementationType.CanBeCastTo() || Descriptor.ImplementationType.CanBeCastTo()) + var implementationType = Descriptor.IsKeyedService ? Descriptor.KeyedImplementationType : Descriptor.ImplementationType; + var frame = new ConstructorFrame(implementationType, Constructor); + if (implementationType.CanBeCastTo() || implementationType.CanBeCastTo()) { frame.Mode = ConstructorCallMode.UsingNestedVariable; } diff --git a/src/Wolverine/Codegen/InjectedSingleton.cs b/src/Wolverine/Codegen/InjectedSingleton.cs index 2df8b540b..221406078 100644 --- a/src/Wolverine/Codegen/InjectedSingleton.cs +++ b/src/Wolverine/Codegen/InjectedSingleton.cs @@ -1,4 +1,5 @@ using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; using Microsoft.Extensions.DependencyInjection; namespace Wolverine.Codegen; @@ -12,7 +13,18 @@ public InjectedSingleton(ServiceDescriptor descriptor) : base(descriptor.Service { Descriptor = descriptor; } - + + public override string CtorArgDeclaration() + { + if (Descriptor.IsKeyedService) + { + return $"[{typeof(FromKeyedServicesAttribute).FullNameInCode().Replace("Attribute", "")}(\"{Descriptor.ServiceKey}\")] " + + base.CtorArgDeclaration(); + } + + return base.CtorArgDeclaration(); + } + public bool IsOnlyOne { private get => _isOnlyOne; @@ -27,4 +39,34 @@ public bool IsOnlyOne } } } + + protected bool Equals(InjectedSingleton other) + { + return base.Equals(other) && Descriptor.Equals(other.Descriptor); + } + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((InjectedSingleton)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), Descriptor); + } } \ No newline at end of file diff --git a/src/Wolverine/Codegen/ServiceCollectionServerVariableSource.cs b/src/Wolverine/Codegen/ServiceCollectionServerVariableSource.cs index 2b43f3713..59ed3c0c4 100644 --- a/src/Wolverine/Codegen/ServiceCollectionServerVariableSource.cs +++ b/src/Wolverine/Codegen/ServiceCollectionServerVariableSource.cs @@ -27,6 +27,24 @@ public bool Matches(Type type) { return _services.CouldResolve(type); } + + public bool TryFindKeyedService(Type type, string key, out Variable? variable) + { + variable = default; + + var descriptor = _services.RegistrationsFor(type).Where(x => x.IsKeyedService) + .FirstOrDefault(x => Equals(x.ServiceKey, key)); + + if (descriptor == null) + { + return false; + } + + var plan = _services.PlanFor(descriptor, []); + + variable = createVariableForPlan(type, plan); + return variable != null; + } public Variable Create(Type type) { @@ -37,8 +55,15 @@ public Variable Create(Type type) } var plan = _services.FindDefault(type, new()); + return createVariableForPlan(type, plan); + } + + private Variable createVariableForPlan(Type type, ServicePlan? plan) + { if (plan is InvalidPlan) + { throw new NotSupportedException($"Cannot build service type {type.FullNameInCode()} in any way"); + } if (plan is null) { @@ -62,7 +87,7 @@ public Variable Create(Type type) return standin; } - + public void ReplaceVariables(IMethodVariables method) { if (_usesScopedContainerDirectly || _standins.Any(x => x.Plan.RequiresServiceProvider(method))) @@ -86,7 +111,7 @@ public void StartNewMethod() _scoped = new ScopedContainerCreation().Scoped; _standins.Clear(); } - + private void useServiceProvider(IMethodVariables method) { var written = false; diff --git a/src/Wolverine/Codegen/ServiceFamily.cs b/src/Wolverine/Codegen/ServiceFamily.cs index 1524bac40..5a7af3244 100644 --- a/src/Wolverine/Codegen/ServiceFamily.cs +++ b/src/Wolverine/Codegen/ServiceFamily.cs @@ -66,16 +66,32 @@ internal ServicePlan BuildPlan(ServiceContainer graph, ServiceDescriptor descrip return new ServiceLocationPlan(descriptor); } -#if NET8_0_OR_GREATER - if (descriptor.IsKeyedService) + if (descriptor.Lifetime == ServiceLifetime.Singleton) { - throw new NotSupportedException("Wolverine is not yet able support keyed implementations in its code generation with the built in ServiceProvider. Wolverine *can* support keyed services with the Lamar IoC container. See https://jasperfx.github.io/lamar for more information"); + return new SingletonPlan(descriptor); } -#endif - if (descriptor.Lifetime == ServiceLifetime.Singleton) + if (descriptor.IsKeyedService) { - return new SingletonPlan(descriptor); + if (descriptor.KeyedImplementationFactory != null) + { + return new ServiceLocationPlan(descriptor); + } + + if (descriptor.KeyedImplementationType.IsNotPublic) + { + return new ServiceLocationPlan(descriptor); + } + + if (!descriptor.KeyedImplementationType.IsConcrete()) + { + return new InvalidPlan(descriptor); + } + + if (ConstructorPlan.TryBuildPlan(trail, descriptor, graph, out var plan2)) + { + return plan2; + } } if (descriptor.ImplementationFactory != null) @@ -83,6 +99,8 @@ internal ServicePlan BuildPlan(ServiceContainer graph, ServiceDescriptor descrip return new ServiceLocationPlan(descriptor); } + + if (!descriptor.ImplementationType.IsConcrete()) { // If you don't know how to create it, you can't use it, period diff --git a/src/Wolverine/Codegen/ServiceVariables.cs b/src/Wolverine/Codegen/ServiceVariables.cs index 9a1256882..08f156d69 100644 --- a/src/Wolverine/Codegen/ServiceVariables.cs +++ b/src/Wolverine/Codegen/ServiceVariables.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Reflection; using JasperFx.CodeGeneration.Model; using Microsoft.Extensions.DependencyInjection; @@ -46,6 +47,11 @@ Variable IMethodVariables.FindVariable(Type type) return null; } + public Variable FindVariable(ParameterInfo parameter) + { + return null; + } + Variable IMethodVariables.FindVariableByName(Type dependency, string name) { return null; diff --git a/src/Wolverine/Runtime/ServiceContainer.cs b/src/Wolverine/Runtime/ServiceContainer.cs index 7dde98c2a..14a906afd 100644 --- a/src/Wolverine/Runtime/ServiceContainer.cs +++ b/src/Wolverine/Runtime/ServiceContainer.cs @@ -137,7 +137,7 @@ public IEnumerable ServiceDependenciesFor(Type serviceType) } var list = new List(); - var plan = planFor(family.Default, list); + var plan = PlanFor(family.Default, list); return plan.FindDependencies().Distinct().ToArray(); } catch (Exception e) @@ -151,7 +151,7 @@ bool IServiceProviderIsService.IsService(Type serviceType) return false; } - private ServicePlan planFor(ServiceDescriptor descriptor, List trail) + internal ServicePlan PlanFor(ServiceDescriptor descriptor, List trail) { if (descriptor == null) { @@ -193,7 +193,7 @@ public bool CouldResolve(Type type, List trail) var descriptor = findDefaultDescriptor(type); if (descriptor == null) return false; - plan = planFor(descriptor, trail); + plan = PlanFor(descriptor, trail); return plan is not InvalidPlan; } @@ -282,7 +282,7 @@ private ServiceFamily findFamily(Type serviceType) internal IReadOnlyList FindAll(Type serviceType, List trail) { - return findFamily(serviceType).Services.Select(descriptor => planFor(descriptor, trail)).ToArray(); + return findFamily(serviceType).Services.Select(descriptor => PlanFor(descriptor, trail)).ToArray(); } public object BuildFromType(Type concreteType) @@ -321,9 +321,30 @@ public object QuickBuild(Type concreteType) var constructor = concreteType.GetConstructors().Single(); var args = constructor .GetParameters() - .Select(x => _provider.GetService(x.ParameterType)) + .Select(x => + { + if (x.TryGetAttribute(out var att)) + { + return typeof(IFinder<>).CloseAndBuildAs(x.ParameterType).Find(_provider, att.Key.ToString()); + } + + return _provider.GetService(x.ParameterType); + }) .ToArray(); return Activator.CreateInstance(concreteType, args); } + + private interface IFinder + { + object Find(IServiceProvider provider, string key); + } + + private class IFinder : IFinder + { + public object Find(IServiceProvider provider, string key) + { + return provider.GetKeyedService(key); + } + } } \ No newline at end of file diff --git a/src/Wolverine/Wolverine.csproj b/src/Wolverine/Wolverine.csproj index 5673fb3bd..043ed846e 100644 --- a/src/Wolverine/Wolverine.csproj +++ b/src/Wolverine/Wolverine.csproj @@ -4,7 +4,7 @@ WolverineFx - +