Skip to content

Commit

Permalink
feat: Added support for named options (#478) (#534)
Browse files Browse the repository at this point in the history
* Added support for named options (#478)

* Updated documentation for named options.

* Added extra comment to documentation.

* chore: refactor to match .NET named options patterns

* chore: mark unnamed tenant options types obsolete

* chore: updated docs

* chore: minor docs update

Co-authored-by: Andrew White <andrew@finbuckle.com>
  • Loading branch information
LamarLugli and AndrewTriesToCode authored Mar 6, 2022
1 parent faeb1dd commit 6f9528d
Show file tree
Hide file tree
Showing 9 changed files with 281 additions and 46 deletions.
95 changes: 78 additions & 17 deletions docs/Options.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# Per-Tenant Options
Finbuckle.MultiTenant integrates with the standard ASP.NET Core [Options pattern](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options) and lets apps customize options distinctly for each tenant. The current tenant determines which options are retrieved via the `IOptions<TOptions>` (or derived) instance's `Value` property and `Get(string name)` method.

A specialized variation of this is [per-tenant authentication](Authentication).
Finbuckle.MultiTenant integrates with the standard ASP.NET
Core [Options pattern](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options) and lets apps
customize options distinctly for each tenant. The current tenant determines which options are retrieved via
the `IOptions<TOptions>` (or derived) instance's `Value` property and `Get(string name)` method.

Per-tenant options will work with *any* options class when using `IOptions<TOptions>`, `IOptionsSnapshot<TOptions>`, or `IOptionsMonitor<TOptions>` with dependency injection or service resolution. This includes an app's own code *and* code internal to ASP.NET Core or other libraries that use the Options pattern. There is one potential caveat: ASP.NET Core and other libraries may internally cache options or exhibit other unexpected behavior resulting in the wrong option values!
A specialized variation of this is [per-tenant authentication](Authentication).

Per-tenant options will work with *any* options class when using `IOptions<TOptions>`, `IOptionsSnapshot<TOptions>`,
or `IOptionsMonitor<TOptions>` with dependency injection or service resolution. This includes an app's own code *and*
code internal to ASP.NET Core or other libraries that use the Options pattern. There is one potential caveat: ASP.NET
Core and other libraries may internally cache options or exhibit other unexpected behavior resulting in the wrong option
values!

Consider a typical scenario in ASP.Net Core, starting with a simple class:

Expand All @@ -15,7 +23,8 @@ public class MyOptions
}
```

In the `ConfigureServices` method of the startup class, `services.Configure<MyOptions>` is called with a delegate or `IConfiguration` parameter to set the option values:
In the `ConfigureServices` method of the startup class, `services.Configure<MyOptions>` is called with a delegate
or `IConfiguration` parameter to set the option values:

```cs
public class Startup
Expand All @@ -29,7 +38,8 @@ public class Startup
}
```

Dependency injection of `IOptions<MyOptions>` into a controller (or anywhere DI can be used) provides access to the options values, which are the same for every tenant at this point:
Dependency injection of `IOptions<MyOptions>` into a controller (or anywhere DI can be used) provides access to the
options values, which are the same for every tenant at this point:

```cs
public MyController : Controller
Expand All @@ -45,24 +55,32 @@ public MyController : Controller
```

## Customizing Options Per Tenant
This sections assumes Finbuckle.MultiTenant is installed and configured. See [Getting Started](GettingStarted) for details.

This sections assumes Finbuckle.MultiTenant is installed and configured. See [Getting Started](GettingStarted) for
details.

Call `WithPerTenantOptions<TOptions>` after `AddMultiTenant<T>` in the `ConfigureServices` method:

```cs
services.AddMultiTenant<TenantInfo>()...
services.AddMultiTenant<MyTenantInfo>()...
.WithPerTenantOptions<MyOptions>((options, tenantInfo) =>
{
options.MyOption1 = (int)tenantInfo.Items["someValue"];
options.MyOption2 = (int)tenantInfo.Items["anotherValue"];
options.MyOption1 = tenantInfo.Option1Value;
options.MyOption2 = tenantInfo.Option2Value;
});
```

The type parameter `TOptions` is the options type being customized per-tenant. The method parameter is an `Action<TOptions, TenantInfo>`. This action will modify the options instance *after* the options normal configuration and *before* its [post configuration](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?#ipostconfigureoptions).
The type parameter `TOptions` is the options type being customized per-tenant. The method parameter is
an `Action<TOptions, TenantInfo>`. This action will modify the options instance *after* the options normal configuration
and *before* its [post configuration](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?#ipostconfigureoptions)
.

`WithPerTenantOptions<TOptions>` can be called multiple times on the same `TOptions`
type and the configuration will run in the respective order.

The same delegate passed to `WithPerTenantOptions<TOptions>` is applied to all options generated of type `TOptions`
regardless of the option name, similar to the .NET `ConfigureAll` method.

Now with the same controller example from above, the option values will be specific to the current tenant:

```cs
Expand All @@ -79,19 +97,62 @@ public MyController : Controller
```

## Named Options
Both named and unnamed options are modified per-tenant. The same delegate passed to `WithPerTenantOptions<TOptions>` is applied to all options generated of type `TOptions` regardless of the option name.

You can configure options by name using the `WithPerTenantNamedOptions<TOptions>` method.

Call `WithPerTenantNamedOptions<TOptions>` after `AddMultiTenant<T>` in the `ConfigureServices` method:

```cs
services.AddMultiTenant<MyTenantInfo>()...
.WithPerTenantNamedOptions<MyOptions>(someOptionsName, (options, tenantInfo) =>
{
// only update options named "someOptionsName"
options.MyOption1 = tenantInfo.Option1Value;
options.MyOption2 = tenantInfo.Option2Value;
});
```

The `string` parameter is the name of the options. The type parameter `TOptions` is the options type being customized
per-tenant. The method parameter is an `Action<string, TOptions, TenantInfo>`. This action will modify the options
instance *after* the options normal configuration and *before*
its [post configuration](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?#ipostconfigureoptions)
.

`WithPerTenantNameOptions<TOptions>` can be called multiple times on the same `TOptions`
type and the configuration will run in the respective order.

The same delegate passed to `WithPerTenantNameOptions<TOptions>` is applied to all options generated of type `TOptions`
regardless of the option name. You can use the `name` argument in the callback to help you set the correct options by
name.

`WithPerTenantOptions<TOptions>` can be used in combination with `WithPerTenantNameOptions<TOptions>` for the same
type `TOptions`. The `WithPerTenantOptions<TOptions>` callbacks will be invoked first, followed by
the `WithPerTenantNameOptions<TOptions>` callbacks.

## Options Caching
Internally ASP.NET Core caches options, and Finbuckle.MultiTenant extends this to cache options per tenant. Caching occurs when a `TOptions` instance is retrieved via `Value` or `Get` on the injected `IOptions<TOptions>` (or derived) instance for the first time for a tenant.

`IOptions<TOptions>` instances are always regenerated when injected so any caching only lasts as long as the specific instance.
Internally ASP.NET Core caches options, and Finbuckle.MultiTenant extends this to cache options per tenant. Caching
occurs when a `TOptions` instance is retrieved via `Value` or `Get` on the injected `IOptions<TOptions>` (or derived)
instance for the first time for a tenant.

`IOptionsSnapshot<TOptions>` instances are generated once per HTTP request and caching will last throughout the entire request.
`IOptions<TOptions>` instances are always regenerated when injected so any caching only lasts as long as the specific
instance.

`IOptionsSnapshot<TOptions>` instances are generated once per HTTP request and caching will last throughout the entire
request.

`IOptionsMonitor<TOptions>` instances persist across HTTP requests and caching can persist for long periods of time.

In some situations cached options may need to be cleared so that the options can be regenerated.

When using per-tenant options via `IOptions<TOptions>` and `IOptionsSnapshot<TOptions>` the injected instance is of type `MultiTenantOptionsManager<TOptions>`. Casting to this type exposes the `Reset()` method which clears any internal caching for the current tenant and cause the options to be regenerated when next accessed via `Value` or `Get(string name)`.

When using per-tenant options with `IOptionsMonitor<TOptions>` each injected instance uses a shared persistent cache. This cache can be retrieved by injecting or resolving an instance of `IOptionsMonitorCache<TOptions>` which has a `Clear()` method that will clear the cache for the current tenant. Casting the `IOptionsMonitorCache<TOptions>` instance to `MultiTenantOptionsCache<TOptions>` exposes the `Clear(string tenantId)` and `ClearAll()` methods. `Clear(string tenantId)` clears cached options for a specific tenant (or the regular non per-tenant options if the parameter is empty or null). `ClearAll()` clears all cached options (including regular non per-tenant options).
When using per-tenant options via `IOptions<TOptions>` and `IOptionsSnapshot<TOptions>` the injected instance is of
type `MultiTenantOptionsManager<TOptions>`. Casting to this type exposes the `Reset()` method which clears any internal
caching for the current tenant and cause the options to be regenerated when next accessed via `Value`
or `Get(string name)`.

When using per-tenant options with `IOptionsMonitor<TOptions>` each injected instance uses a shared persistent cache.
This cache can be retrieved by injecting or resolving an instance of `IOptionsMonitorCache<TOptions>` which has
a `Clear()` method that will clear the cache for the current tenant. Casting the `IOptionsMonitorCache<TOptions>`
instance to `MultiTenantOptionsCache<TOptions>` exposes the `Clear(string tenantId)` and `ClearAll()`
methods. `Clear(string tenantId)` clears cached options for a specific tenant (or the regular non per-tenant options if
the parameter is empty or null). `ClearAll()` clears all cached options (including regular non per-tenant options).
62 changes: 50 additions & 12 deletions src/Finbuckle.MultiTenant/DependencyInjection/MultiTenantBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,60 @@ public FinbuckleMultiTenantBuilder(IServiceCollection services)
/// </summary>
/// <param name="tenantConfigureOptions">The configuration action to be run for each tenant.</param>
/// <returns>The same MultiTenantBuilder passed into the method.</returns>
public FinbuckleMultiTenantBuilder<TTenantInfo> WithPerTenantOptions<TOptions>(Action<TOptions, TTenantInfo> tenantConfigureOptions) where TOptions : class, new()
/// <remarks>This is similar to `ConfigureAll` in that it applies to all named and unnamed options of the type.</remarks>
public FinbuckleMultiTenantBuilder<TTenantInfo> WithPerTenantOptions<TOptions>(
Action<TOptions, TTenantInfo> tenantConfigureOptions) where TOptions : class, new()
{
if (tenantConfigureOptions == null)
// if (tenantConfigureOptions == null)
// {
// throw new ArgumentNullException(nameof(tenantConfigureOptions));
// }
//
// // Handles multiplexing cached options.
// Services.TryAddSingleton<IOptionsMonitorCache<TOptions>, MultiTenantOptionsCache<TOptions, TTenantInfo>>();
//
// // Necessary to apply tenant options in between configuration and postconfiguration
// Services
// .AddSingleton<ITenantConfigureOptions<TOptions, TTenantInfo>,
// TenantConfigureOptions<TOptions, TTenantInfo>>(sp =>
// new TenantConfigureOptions<TOptions, TTenantInfo>(tenantConfigureOptions));
// Services.TryAddTransient<IOptionsFactory<TOptions>, MultiTenantOptionsFactory<TOptions, TTenantInfo>>();
// Services.TryAddScoped<IOptionsSnapshot<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));
// Services.TryAddSingleton<IOptions<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));

return WithPerTenantNamedOptions(null, tenantConfigureOptions);
}

/// <summary>
/// Adds per-tenant configuration for an named options class.
/// </summary>
/// <param name="name">The option name.</param>
/// <param name="tenantConfigureNamedOptions">The configuration action to be run for each tenant.</param>
/// <returns>The same MultiTenantBuilder passed into the method.</returns>
public FinbuckleMultiTenantBuilder<TTenantInfo> WithPerTenantNamedOptions<TOptions>(string? name,
Action<TOptions, TTenantInfo> tenantConfigureNamedOptions) where TOptions : class, new()
{
if (tenantConfigureNamedOptions == null)
{
throw new ArgumentNullException(nameof(tenantConfigureOptions));
throw new ArgumentNullException(nameof(tenantConfigureNamedOptions));
}

// Handles multiplexing cached options.
Services.TryAddSingleton<IOptionsMonitorCache<TOptions>, MultiTenantOptionsCache<TOptions, TTenantInfo>>();

// Necessary to apply tenant options in between configuration and postconfiguration
Services.AddSingleton<ITenantConfigureOptions<TOptions, TTenantInfo>, TenantConfigureOptions<TOptions, TTenantInfo>>(sp => new TenantConfigureOptions<TOptions, TTenantInfo>(tenantConfigureOptions));
// Necessary to apply tenant named options in between configuration and postconfiguration
Services.AddSingleton<ITenantConfigureNamedOptions<TOptions, TTenantInfo>,
TenantConfigureNamedOptions<TOptions, TTenantInfo>>(sp => new TenantConfigureNamedOptions<TOptions,
TTenantInfo>(name, tenantConfigureNamedOptions));
Services.TryAddTransient<IOptionsFactory<TOptions>, MultiTenantOptionsFactory<TOptions, TTenantInfo>>();
Services.TryAddScoped<IOptionsSnapshot<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));
Services.TryAddSingleton<IOptions<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));

return this;
}


private static MultiTenantOptionsManager<TOptions> BuildOptionsManager<TOptions>(IServiceProvider sp) where TOptions : class, new()
private static MultiTenantOptionsManager<TOptions> BuildOptionsManager<TOptions>(IServiceProvider sp)
where TOptions : class, new()
{
var cache = ActivatorUtilities.CreateInstance(sp, typeof(MultiTenantOptionsCache<TOptions, TTenantInfo>));
return (MultiTenantOptionsManager<TOptions>)
Expand All @@ -56,7 +89,8 @@ public FinbuckleMultiTenantBuilder(IServiceCollection services)
/// <param name="lifetime">The service lifetime.</param>
/// <param name="parameters">a paramter list for any constructor paramaters not covered by dependency injection.</param>
/// <returns>The same MultiTenantBuilder passed into the method.</returns>
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStore<TStore>(ServiceLifetime lifetime, params object[] parameters)
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStore<TStore>(ServiceLifetime lifetime,
params object[] parameters)
where TStore : IMultiTenantStore<TTenantInfo>
=> WithStore<TStore>(lifetime, sp => ActivatorUtilities.CreateInstance<TStore>(sp, parameters));

Expand All @@ -66,7 +100,8 @@ public FinbuckleMultiTenantBuilder<TTenantInfo> WithStore<TStore>(ServiceLifetim
/// <param name="lifetime">The service lifetime.</param>
/// <param name="factory">A delegate that will create and configure the store.</param>
/// <returns>The same MultiTenantBuilder passed into the method.</returns>
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStore<TStore>(ServiceLifetime lifetime, Func<IServiceProvider, TStore> factory)
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStore<TStore>(ServiceLifetime lifetime,
Func<IServiceProvider, TStore> factory)
where TStore : IMultiTenantStore<TTenantInfo>
{
if (factory == null)
Expand All @@ -75,7 +110,8 @@ public FinbuckleMultiTenantBuilder<TTenantInfo> WithStore<TStore>(ServiceLifetim
}

// Note: can't use TryAddEnumerable here because ServiceDescriptor.Describe with a factory can't set implementation type.
Services.Add(ServiceDescriptor.Describe(typeof(IMultiTenantStore<TTenantInfo>), sp => factory(sp), lifetime));
Services.Add(
ServiceDescriptor.Describe(typeof(IMultiTenantStore<TTenantInfo>), sp => factory(sp), lifetime));

return this;
}
Expand All @@ -86,7 +122,8 @@ public FinbuckleMultiTenantBuilder<TTenantInfo> WithStore<TStore>(ServiceLifetim
/// <param name="lifetime">The service lifetime.</param>
/// <param name="parameters">a paramter list for any constructor paramaters not covered by dependency injection.</param>
/// <returns>The same MultiTenantBuilder passed into the method.</returns>
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStrategy<TStrategy>(ServiceLifetime lifetime, params object[] parameters) where TStrategy : IMultiTenantStrategy
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStrategy<TStrategy>(ServiceLifetime lifetime,
params object[] parameters) where TStrategy : IMultiTenantStrategy
=> WithStrategy(lifetime, sp => ActivatorUtilities.CreateInstance<TStrategy>(sp, parameters));

/// <summary>
Expand All @@ -95,7 +132,8 @@ public FinbuckleMultiTenantBuilder<TTenantInfo> WithStrategy<TStrategy>(ServiceL
/// <param name="lifetime">The service lifetime.</param>
/// <param name="factory">A delegate that will create and configure the strategy.</param>
/// <returns>The same MultiTenantBuilder passed into the method.</returns>
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStrategy<TStrategy>(ServiceLifetime lifetime, Func<IServiceProvider, TStrategy> factory)
public FinbuckleMultiTenantBuilder<TTenantInfo> WithStrategy<TStrategy>(ServiceLifetime lifetime,
Func<IServiceProvider, TStrategy> factory)
where TStrategy : IMultiTenantStrategy
{
if (factory == null)
Expand Down
12 changes: 12 additions & 0 deletions src/Finbuckle.MultiTenant/Options/ITenantConfigureNamedOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright Finbuckle LLC, Andrew White, and Contributors.
// Refer to the solution LICENSE file for more inforation.

namespace Finbuckle.MultiTenant.Options
{
public interface ITenantConfigureNamedOptions<TOptions, TTenantInfo>
where TOptions : class, new()
where TTenantInfo : class, ITenantInfo, new()
{
void Configure(string name, TOptions options, TTenantInfo tenantInfo);
}
}
3 changes: 3 additions & 0 deletions src/Finbuckle.MultiTenant/Options/ITenantConfigureOptions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// Copyright Finbuckle LLC, Andrew White, and Contributors.
// Refer to the solution LICENSE file for more inforation.

using System;

namespace Finbuckle.MultiTenant.Options
{
[Obsolete]
public interface ITenantConfigureOptions<TOptions, TTenantInfo>
where TOptions : class, new()
where TTenantInfo : class, ITenantInfo, new()
Expand Down
Loading

0 comments on commit 6f9528d

Please sign in to comment.