diff --git a/src/Finbuckle.MultiTenant/DependencyInjection/MultiTenantBuilder.cs b/src/Finbuckle.MultiTenant/DependencyInjection/MultiTenantBuilder.cs index 6dc6e1c2..8637e9f0 100644 --- a/src/Finbuckle.MultiTenant/DependencyInjection/MultiTenantBuilder.cs +++ b/src/Finbuckle.MultiTenant/DependencyInjection/MultiTenantBuilder.cs @@ -40,7 +40,7 @@ public FinbuckleMultiTenantBuilder WithPerTenantOptions( Action tenantConfigureOptions) where TOptions : class, new() { // TODO maybe change this to string empty so null an be used for all options, note remarks. - return WithPerTenantNamedOptions(null, tenantConfigureOptions); + return WithPerTenantNamedOptions(null, tenantConfigureOptions); } /// @@ -58,32 +58,16 @@ public FinbuckleMultiTenantBuilder WithPerTenantNamedOptions(string throw new ArgumentNullException(nameof(tenantConfigureNamedOptions)); } - // Handles multiplexing cached options. - Services.TryAddSingleton, MultiTenantOptionsCache>(); - - // Necessary to apply tenant named options in between configuration and post configuration - Services.AddSingleton, - TenantConfigureNamedOptions>(_ => new TenantConfigureNamedOptions(name, tenantConfigureNamedOptions)); - Services.TryAddTransient, MultiTenantOptionsFactory>(); - Services.TryAddScoped>(BuildOptionsManager); - Services.TryAddSingleton>(BuildOptionsManager); + Services.AddPerTenantOptionsCore(); + Services.TryAddEnumerable(ServiceDescriptor.Scoped, TenantConfigureNamedOptionsWrapper>()); + Services.AddScoped>(sp => new TenantConfigureNamedOptions(name, tenantConfigureNamedOptions)); return this; } // TODO consider per tenant AllOptions variation // TODO consider per-tenant post options - // TODO consider OptionsBuilder api - - private static MultiTenantOptionsManager BuildOptionsManager(IServiceProvider sp) - where TOptions : class, new() - { - var cache = (IOptionsMonitorCache)ActivatorUtilities.CreateInstance(sp, - typeof(MultiTenantOptionsCache)); - return (MultiTenantOptionsManager) - ActivatorUtilities.CreateInstance(sp, typeof(MultiTenantOptionsManager), cache); - } + /// /// Adds and configures an IMultiTenantStore to the application using default dependency injection. diff --git a/src/Finbuckle.MultiTenant/Extensions/ServiceCollectionExtensions.cs b/src/Finbuckle.MultiTenant/Extensions/ServiceCollectionExtensions.cs index 62b1c35a..a8db44ad 100644 --- a/src/Finbuckle.MultiTenant/Extensions/ServiceCollectionExtensions.cs +++ b/src/Finbuckle.MultiTenant/Extensions/ServiceCollectionExtensions.cs @@ -5,6 +5,9 @@ using System.Linq; using Finbuckle.MultiTenant; using Finbuckle.MultiTenant.Internal; +using Finbuckle.MultiTenant.Options; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; @@ -55,6 +58,29 @@ public static FinbuckleMultiTenantBuilder AddMultiTenant(this IServiceColl return services.AddMultiTenant(_ => { }); } + /// + /// Gets an options builder that forwards Configure calls for the same named per-tenant to the underlying service collection. + /// + /// The options type to be configured. + /// The to add the services to. + /// The name of the options instance. + /// The so that configure calls can be chained in it. + public static OptionsBuilder AddPerTenantOptions(this IServiceCollection services, string? name) where TOptions : class, new() + { + + services.AddPerTenantOptionsCore(); + return new OptionsBuilder(services, name); + } + + /// + /// Gets an options builder that forwards Configure calls for the same per-tenant to the underlying service collection. + /// + /// The options type to be configured. + /// The to add the services to. + /// The so that configure calls can be chained in it. + public static OptionsBuilder AddPerTenantOptions(this IServiceCollection services) where TOptions : class, new() => + services.AddPerTenantOptions(Options.Options.DefaultName); + public static bool DecorateService(this IServiceCollection services, params object[] parameters) { var existingService = services.SingleOrDefault(s => s.ServiceType == typeof(TService)); @@ -118,4 +144,27 @@ public static bool DecorateService(this IServiceCollection serv return true; } + + internal static void AddPerTenantOptionsCore(this IServiceCollection services) where TOptions : class, new() + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + // Handles multiplexing cached options. + services.TryAddSingleton, MultiTenantOptionsCache>(); + services.TryAddTransient, MultiTenantOptionsFactory>(); + services.TryAddScoped>(BuildOptionsManager); + services.TryAddSingleton>(BuildOptionsManager); + } + + private static MultiTenantOptionsManager BuildOptionsManager(IServiceProvider sp) + where TOptions : class, new() + { + var cache = (IOptionsMonitorCache)ActivatorUtilities.CreateInstance(sp, + typeof(MultiTenantOptionsCache)); + return (MultiTenantOptionsManager) + ActivatorUtilities.CreateInstance(sp, typeof(MultiTenantOptionsManager), cache); + } } \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/Options/ITenantConfigureNamedOptionsWrapper.cs b/src/Finbuckle.MultiTenant/Options/ITenantConfigureNamedOptionsWrapper.cs new file mode 100644 index 00000000..d5749a11 --- /dev/null +++ b/src/Finbuckle.MultiTenant/Options/ITenantConfigureNamedOptionsWrapper.cs @@ -0,0 +1,11 @@ +// Copyright Finbuckle LLC, Andrew White, and Contributors. +// Refer to the solution LICENSE file for more information. + +using Microsoft.Extensions.Options; + +namespace Finbuckle.MultiTenant.Options; + +interface ITenantConfigureNamedOptionsWrapper : IConfigureNamedOptions + where TOptions : class, new() +{ +} diff --git a/src/Finbuckle.MultiTenant/Options/ITenantConfigureOptions.cs b/src/Finbuckle.MultiTenant/Options/ITenantConfigureOptions.cs index 4215fb70..04f684d9 100644 --- a/src/Finbuckle.MultiTenant/Options/ITenantConfigureOptions.cs +++ b/src/Finbuckle.MultiTenant/Options/ITenantConfigureOptions.cs @@ -1,6 +1,7 @@ // Copyright Finbuckle LLC, Andrew White, and Contributors. // Refer to the solution LICENSE file for more information. +using Microsoft.Extensions.Options; using System; namespace Finbuckle.MultiTenant.Options; @@ -11,4 +12,4 @@ public interface ITenantConfigureOptions where TTenantInfo : class, ITenantInfo, new() { void Configure(TOptions options, TTenantInfo tenantInfo); -} \ No newline at end of file +} diff --git a/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsCache.cs b/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsCache.cs index bf24f075..66daa3e8 100644 --- a/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsCache.cs +++ b/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsCache.cs @@ -7,6 +7,111 @@ namespace Finbuckle.MultiTenant.Options; +/// +/// Adds, retrieves, and removes instances of TOptions after adjusting them for the current TenantContext. +/// +public class MultiTenantOptionsCache : IOptionsMonitorCache + where TOptions : class +{ + private readonly IMultiTenantContextAccessor multiTenantContextAccessor; + + // The object is just a dummy because there is no ConcurrentSet class. + //private readonly ConcurrentDictionary> _adjustedOptionsNames = + // new ConcurrentDictionary>(); + + private readonly ConcurrentDictionary> map = new ConcurrentDictionary>(); + + /// + /// Constructs a new instance of MultiTenantOptionsCache. + /// + /// + /// + public MultiTenantOptionsCache(IMultiTenantContextAccessor multiTenantContextAccessor) + { + this.multiTenantContextAccessor = multiTenantContextAccessor ?? throw new ArgumentNullException(nameof(multiTenantContextAccessor)); + } + + /// + /// Clears all cached options for the current tenant. + /// + public void Clear() + { + var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? ""; + var cache = map.GetOrAdd(tenantId, new OptionsCache()); + + cache.Clear(); + } + + /// + /// Clears all cached options for the given tenant. + /// + /// The Id of the tenant which will have its options cleared. + public void Clear(string tenantId) + { + var cache = map.GetOrAdd(tenantId, new OptionsCache()); + + cache.Clear(); + } + + /// + /// Clears all cached options for all tenants and no tenant. + /// + public void ClearAll() + { + foreach (var cache in map.Values) + cache.Clear(); + } + + /// + /// Gets a named options instance for the current tenant, or adds a new instance created with createOptions. + /// + /// The options name. + /// The factory function for creating the options instance. + /// The existing or new options instance. + public TOptions GetOrAdd(string? name, Func createOptions) + { + if (createOptions == null) + { + throw new ArgumentNullException(nameof(createOptions)); + } + + name = name ?? Microsoft.Extensions.Options.Options.DefaultName; + var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? ""; + var cache = map.GetOrAdd(tenantId, new OptionsCache()); + + return cache.GetOrAdd(name, createOptions); + } + + /// + /// Tries to adds a new option to the cache for the current tenant. + /// + /// The options name. + /// The options instance. + /// True if the options was added to the cache for the current tenant. + public bool TryAdd(string? name, TOptions options) + { + name = name ?? Microsoft.Extensions.Options.Options.DefaultName; + var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? ""; + var cache = map.GetOrAdd(tenantId, new OptionsCache()); + + return cache.TryAdd(name, options); + } + + /// + /// Try to remove an options instance for the current tenant. + /// + /// The options name. + /// True if the options was removed from the cache for the current tenant. + public bool TryRemove(string? name) + { + name = name ?? Microsoft.Extensions.Options.Options.DefaultName; + var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? ""; + var cache = map.GetOrAdd(tenantId, new OptionsCache()); + + return cache.TryRemove(name); + } +} + /// /// Adds, retrieves, and removes instances of TOptions after adjusting them for the current TenantContext. /// @@ -59,7 +164,7 @@ public void Clear(string tenantId) /// public void ClearAll() { - foreach(var cache in map.Values) + foreach (var cache in map.Values) cache.Clear(); } diff --git a/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsFactory.cs b/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsFactory.cs index d73c2c7e..042ab843 100644 --- a/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsFactory.cs +++ b/src/Finbuckle.MultiTenant/Options/MultiTenantOptionsFactory.cs @@ -4,16 +4,103 @@ // Portions of this file are derived from the .NET Foundation source file located at: // https://github.com/dotnet/runtime/blob/5aad989cebe00f0987fcb842ea5b7cbe986c67df/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs +using System; using System.Collections.Generic; using Microsoft.Extensions.Options; namespace Finbuckle.MultiTenant.Options; +/// +/// Implementation of IOptionsFactory. +/// +/// The type of options being requested. +public class MultiTenantOptionsFactory : IOptionsFactory + where TOptions : class, new() +{ + private readonly IConfigureOptions[] _configureOptions; + private readonly IPostConfigureOptions[] _postConfigureOptions; + private readonly IValidateOptions[] _validations; + + /// + /// Initializes a new instance with the specified options configurations. + /// + public MultiTenantOptionsFactory( + IEnumerable> configureOptions, + IEnumerable> postConfigureOptions, + IEnumerable> validations) + { + // The default DI container uses arrays under the covers. Take advantage of this knowledge + // by checking for an array and enumerate over that, so we don't need to allocate an enumerator. + // When it isn't already an array, convert it to one, but don't use System.Linq to avoid pulling Linq in to + // small trimmed applications. + + _configureOptions = configureOptions as IConfigureOptions[] ?? + new List>(configureOptions).ToArray(); + _postConfigureOptions = postConfigureOptions as IPostConfigureOptions[] ?? + new List>(postConfigureOptions).ToArray(); + _validations = validations as IValidateOptions[] ?? + new List>(validations).ToArray(); + } + + /// + public TOptions Create(string name) + { + ITenantConfigureNamedOptionsWrapper? tenantConfigureNamedOptionsWrapper = null; + var options = new TOptions(); + foreach (var setup in _configureOptions) + { + // consider directly injecting this to avoid a conditional. + if (setup is ITenantConfigureNamedOptionsWrapper wrapper) + { + tenantConfigureNamedOptionsWrapper = wrapper; + } + else if (setup is IConfigureNamedOptions namedSetup) + { + namedSetup.Configure(name, options); + } + else if (name == Microsoft.Extensions.Options.Options.DefaultName) + { + setup.Configure(options); + } + } + + tenantConfigureNamedOptionsWrapper?.Configure(name, options); + + foreach (var post in _postConfigureOptions) + { + post.PostConfigure(name, options); + } + + // TODO consider per tenant post configure + + if (_validations.Length > 0) + { + var failures = new List(); + foreach (IValidateOptions validate in _validations) + { + ValidateOptionsResult result = validate.Validate(name, options); + if (result is { Failed: true }) + { + failures.AddRange(result.Failures); + } + } + + if (failures.Count > 0) + { + throw new OptionsValidationException(name, typeof(TOptions), failures); + } + } + + return options; + } +} + /// /// Implementation of IOptionsFactory. /// /// The type of options being requested. /// The type of the tenant info. +[Obsolete] public class MultiTenantOptionsFactory : IOptionsFactory where TOptions : class, new() where TTenantInfo : class, ITenantInfo, new() @@ -22,7 +109,9 @@ public class MultiTenantOptionsFactory : IOptionsFactory< private readonly IPostConfigureOptions[] _postConfigureOptions; private readonly IValidateOptions[] _validations; +#pragma warning disable CS0612 // Type or member is obsolete private readonly ITenantConfigureOptions[] _tenantConfigureOptions; +#pragma warning restore CS0612 // Type or member is obsolete private readonly ITenantConfigureNamedOptions[] _tenantConfigureNamedOptions; private readonly IMultiTenantContextAccessor _multiTenantContextAccessor; @@ -32,7 +121,9 @@ public class MultiTenantOptionsFactory : IOptionsFactory< public MultiTenantOptionsFactory(IEnumerable> configureOptions, IEnumerable> postConfigureOptions, IEnumerable> validations, +#pragma warning disable CS0612 // Type or member is obsolete IEnumerable> tenantConfigureOptions, +#pragma warning restore CS0612 // Type or member is obsolete IEnumerable> tenantConfigureNamedOptions, IMultiTenantContextAccessor multiTenantContextAccessor) { @@ -47,9 +138,11 @@ public MultiTenantOptionsFactory(IEnumerable> config new List>(postConfigureOptions).ToArray(); _validations = validations as IValidateOptions[] ?? new List>(validations).ToArray(); +#pragma warning disable CS0612 // Type or member is obsolete _tenantConfigureOptions = tenantConfigureOptions as ITenantConfigureOptions[] ?? new List>(tenantConfigureOptions) .ToArray(); +#pragma warning restore CS0612 // Type or member is obsolete _tenantConfigureNamedOptions = tenantConfigureNamedOptions as ITenantConfigureNamedOptions[] ?? new List>(tenantConfigureNamedOptions).ToArray(); @@ -88,7 +181,7 @@ public TOptions Create(string name) { post.PostConfigure(name, options); } - + // TODO consider per tenant post configure if (_validations.Length > 0) diff --git a/src/Finbuckle.MultiTenant/Options/TenantConfigureNamedOptionsWrapper.cs b/src/Finbuckle.MultiTenant/Options/TenantConfigureNamedOptionsWrapper.cs new file mode 100644 index 00000000..baba7df2 --- /dev/null +++ b/src/Finbuckle.MultiTenant/Options/TenantConfigureNamedOptionsWrapper.cs @@ -0,0 +1,55 @@ +// Copyright Finbuckle LLC, Andrew White, and Contributors. +// Refer to the solution LICENSE file for more information. + +using System.Collections.Generic; + +namespace Finbuckle.MultiTenant.Options; + +class TenantConfigureNamedOptionsWrapper : ITenantConfigureNamedOptionsWrapper + where TOptions : class, new() + where TTenantInfo : class, ITenantInfo, new() +{ + private readonly IMultiTenantContextAccessor multiTenantContextAccessor; + private readonly ITenantConfigureOptions[] tenantConfigureOptions; + private readonly ITenantConfigureNamedOptions[] tenantConfigureNamedOptions; + + public TenantConfigureNamedOptionsWrapper( + IMultiTenantContextAccessor multiTenantContextAccessor, + IEnumerable> tenantConfigureOptions, + IEnumerable> tenantConfigureNamedOptions) + { + this.multiTenantContextAccessor = multiTenantContextAccessor; + this.tenantConfigureOptions = tenantConfigureOptions as ITenantConfigureOptions[] ?? + new List>(tenantConfigureOptions).ToArray(); + this.tenantConfigureNamedOptions = tenantConfigureNamedOptions as ITenantConfigureNamedOptions[] ?? + new List>(tenantConfigureNamedOptions).ToArray(); + } + + public void Configure(string name, TOptions options) + { + if (multiTenantContextAccessor.MultiTenantContext?.HasResolvedTenant ?? false) + { + foreach (var tenantConfigureOption in tenantConfigureOptions) + tenantConfigureOption.Configure(options, multiTenantContextAccessor.MultiTenantContext.TenantInfo!); + + // Configure tenant named options. + foreach (var tenantConfigureNamedOption in tenantConfigureNamedOptions) + tenantConfigureNamedOption.Configure(name, options, + multiTenantContextAccessor.MultiTenantContext.TenantInfo!); + } + } + + public void Configure(TOptions options) + { + if (multiTenantContextAccessor.MultiTenantContext?.HasResolvedTenant ?? false) + { + foreach (var tenantConfigureOption in tenantConfigureOptions) + tenantConfigureOption.Configure(options, multiTenantContextAccessor.MultiTenantContext.TenantInfo!); + + // Configure tenant named options. + foreach (var tenantConfigureNamedOption in tenantConfigureNamedOptions) + tenantConfigureNamedOption.Configure(Microsoft.Extensions.Options.Options.DefaultName, options, + multiTenantContextAccessor.MultiTenantContext.TenantInfo!); + } + } +} diff --git a/test/Finbuckle.MultiTenant.Test/DependencyInjection/ServiceCollectionShould.cs b/test/Finbuckle.MultiTenant.Test/DependencyInjection/ServiceCollectionShould.cs new file mode 100644 index 00000000..9f4b7606 --- /dev/null +++ b/test/Finbuckle.MultiTenant.Test/DependencyInjection/ServiceCollectionShould.cs @@ -0,0 +1,35 @@ +// Copyright Finbuckle LLC, Andrew White, and Co ntributors. +// Refer to the solution LICENSE file for more information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Finbuckle.MultiTenant.Test.DependencyInjection +{ + public class ServiceCollectionShould + { + // Used in some tests. + // ReSharper disable once UnusedAutoPropertyAccessor.Local + private string? TestProperty { get; set; } + + [Fact] + public void AddPerTenantOptions() + { + var services = new ServiceCollection(); + _ = services.AddMultiTenant(); + + services.AddPerTenantOptions() + .Configure((o, ti) => o.TestProperty = ti.Id); + + var sp = services.BuildServiceProvider(); + var multiTenantContextAccessor = sp.GetRequiredService>(); + multiTenantContextAccessor.MultiTenantContext = new MultiTenantContext{ TenantInfo = new TenantInfo { Id = "initech" } }; + + using var scope = sp.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + Assert.Equal("initech", options.Value.TestProperty); + } + } +} \ No newline at end of file