From 956ca36670aa8aed38afcbbbdd78f1b79d91287c Mon Sep 17 00:00:00 2001 From: Andrew White Date: Sun, 10 Nov 2024 16:05:06 -0700 Subject: [PATCH] feat: better tenant resolution events (#897) BREAKING CHANGE: `OnTenantResolved` and `OnTenantNotResolved` are no longer used. Use the `OnStrategyResolveCompleted`, `OnStoreResolveCompleted`, and `OnTenantResolveCompleted` events instead. --- docs/Authentication.md | 2 +- docs/ConfigurationAndUsage.md | 79 +++++++++++------ docs/CoreConcepts.md | 2 +- docs/EFCore.md | 2 +- docs/GettingStarted.md | 20 ++--- docs/Identity.md | 2 +- docs/Options.md | 2 +- docs/Stores.md | 19 +++-- docs/Strategies.md | 14 +-- .../MultiTenantBuilderExtensions.cs | 14 +-- .../ServiceCollectionExtensions.cs | 4 +- .../Events/MultiTenantEvents.cs | 20 +++-- .../Events/StoreResolveCompletedContext.cs | 34 ++++++++ .../Events/StrategyResolveCompletedContext.cs | 32 +++++++ .../Events/TenantNotFoundContext.cs | 21 ----- .../Events/TenantResolveCompletedContext.cs | 26 ++++++ .../Events/TenantResolvedContext.cs | 39 --------- .../MultiTenantOptions.cs | 11 ++- .../Strategies/StaticStrategy.cs | 4 +- src/Finbuckle.MultiTenant/TenantResolver.cs | 72 +++++++++------- .../ServiceCollectionExtensionsShould.cs | 2 +- .../TenantResolverShould.cs | 85 +++++++++++++------ 22 files changed, 312 insertions(+), 194 deletions(-) create mode 100644 src/Finbuckle.MultiTenant/Events/StoreResolveCompletedContext.cs create mode 100644 src/Finbuckle.MultiTenant/Events/StrategyResolveCompletedContext.cs delete mode 100644 src/Finbuckle.MultiTenant/Events/TenantNotFoundContext.cs create mode 100644 src/Finbuckle.MultiTenant/Events/TenantResolveCompletedContext.cs delete mode 100644 src/Finbuckle.MultiTenant/Events/TenantResolvedContext.cs diff --git a/docs/Authentication.md b/docs/Authentication.md index 908873e4..3eb0fda9 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -154,7 +154,7 @@ Internally `WithPerTenantAuthentication()` makes use of For example, if you want to configure JWT tokens so that each tenant has a different recognized authority for token validation we can add a field to the `ITenantInfo` implementation and configure the option per-tenant. Any options configured will overwrite earlier -configureations: +configurations: ```csharp builder.Services.AddMultiTenant() diff --git a/docs/ConfigurationAndUsage.md b/docs/ConfigurationAndUsage.md index 47e1eb29..69d792af 100644 --- a/docs/ConfigurationAndUsage.md +++ b/docs/ConfigurationAndUsage.md @@ -4,7 +4,7 @@ Finbuckle.MultiTenant uses the standard application builder pattern for its configuration. In addition to adding the services, configuration for one or more [MultiTenant Stores](Stores) and [MultiTenant Strategies](Strategies) are -required: +required. A typical configuration for an ASP.NET Core application might look like this: ```csharp using Finbuckle.MultiTenant; @@ -30,10 +30,10 @@ app.Run(); ## Adding the Finbuckle.MultiTenant Service -Use the `AddMultiTenant` extension method on `IServiceCollection` to register the basic dependencies -needed by the library. It returns a `MultiTenantBuilder` instance on which the methods below can be called -for further configuration. Each of these methods returns the same `MultiTenantBuilder` instance allowing -for chaining method calls. +Use the `AddMultiTenant` extension method on `IServiceCollection` to register the basic dependencies needed +by the library. It returns a `MultiTenantBuilder` instance on which the methods below can be called for +further configuration. Each of these methods returns the same `MultiTenantBuilder` instance allowing for +chaining method calls. ## Configuring the Service @@ -70,17 +70,42 @@ Configures support for per-tenant authentication. See [Per-Tenant Authentication ## Per-Tenant Options -Finbuckle.MultiTenant integrates with the -standard [.NET Options pattern](https://learn.microsoft.com/en-us/dotnet/core/extensions/options) (see also the [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. See [Per-Tenant Options](Options) for more details. - -## Tenant Resolution - -Most of the capability enabled by Finbuckle.MultiTenant is utilized through its middleware and use -the [Options pattern with per-tenant options](Options). For web applications the middleware will resolve the app's -current tenant on each request using the configured strategies and stores, and the per-tenant -options will alter the app's behavior as dependency injection passes the options to app components. +Finbuckle.MultiTenant id designed to integrate with the +standard [.NET Options pattern](https://learn.microsoft.com/en-us/dotnet/core/extensions/options) (see also +the [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. See [Per-Tenant Options](Options) for more details. + +## Tenant Resolution and Usage + +Finbuckle.MultiTenant will perform tenant resolution using the context, strategies, and stores as configured. + +The context will determine on the type of app. For an ASP.NET Core web app the context is the `HttpContext` for each +request and a tenant will be resolved for each request. For other types of apps the context will be different. For +example, a console app might resolve the tenant once at startup or a background service monitoring a queue might resolve +the tenant for each message it receives. + +Tenant resolution is performed by the `TenantResolver` class. The class requires a list of strategies and a list of +stores as well as some options. The class will try each strategy generally in the order added, but static and per-tenant +authentication strategies will run at a lower priority. If a strategy returns a tenant identifier then each store will +be queried in the order they were added. The first store to return a `TenantInfo` +object will determine the resolved tenant. If no store returns a `TenantInfo` object then the next strategy will be +tried and so on. The `UseMultiTenant` middleware for ASP.NET Core uses `TenantResolver` +internally. + +The `TenantResolver` options are configured in the `AddMultiTenant` method with the following properties: + +- `IgnoredIdentifiers` - A list of tenant identifiers that should be ignored by the resolver. +- `Events` - A set of events that can be used to hook into the resolution process: + - `OnStrategyResolveCompleted` - Called after each strategy has attempted to resolve a tenant identifier. The + `IdentifierFound` property will be `true` if the strategy resolved a tenant identifier. The `Identifier` property + contains the resolved tenant identifier and can be changed by the event handler to override the strategy's result. + - `OnStoreResolveCompleted` - Called after each store has attempted to resolve a tenant. The `TenantFound` property + will be `true` if the store resolved a tenant. The `TenantInfo` property contains the resolved tenant and can be + changed by the event handler to override the store's result. A non-null `TenantInfo` object will stop the resolver + from trying additional strategies and stores. + - `OnTenantResolveCompleted` - Called once after a tenant has been resolved. The `MultiTenantContext` property + contains the resolved multi-tenant context and can be changed by the event handler to override the resolver's + result. ## Getting the Current Tenant @@ -95,8 +120,8 @@ There are several ways an app can see the current tenant: extension `GetMultiTenantContext` to avoid this caveat. * `IMultiTenantContextSetter` is available via dependency injection and can be used to set the current tenant. This is - useful in advanced scenarios and should be used with caution. Prefer using the `HttpContext` extension - method `TrySetTenantInfo` in use cases where `HttpContext` is available. + useful in advanced scenarios and should be used with caution. Prefer using the `HttpContext` extension method + `TrySetTenantInfo` in use cases where `HttpContext` is available. > Prior versions of Finbuckle.MultiTenant also exposed `IMultiTenantContext`, `ITenantInfo`, and their implementations > via dependency injection. This was removed as these are not actual services, similar to @@ -109,9 +134,8 @@ For web apps these convenience methods are also available: * `GetMultiTenantContext` - Use this `HttpContext` extension method to get the `MultiTenantContext` instance for the current - request. This should be preferred to `IMultiTenantContextAccessor` or `IMultiTenantContextAccessor` when - possible. + Use this `HttpContext` extension method to get the `MultiTenantContext` instance for the current request. + This should be preferred to `IMultiTenantContextAccessor` or `IMultiTenantContextAccessor` when possible. ```csharp var tenantInfo = HttpContext.GetMultiTenantContext().TenantInfo; @@ -125,17 +149,16 @@ For web apps these convenience methods are also available: } ``` -* `TrySetTenantInfo` +* `SetTenantInfo` - For most cases the middleware sets the `TenantInfo` and this method is not needed. Use only if explicitly - overriding the `TenantInfo` set by the middleware. + For most cases the middleware sets the `TenantInfo` and this method is not needed. Use only if explicitly overriding + the `TenantInfo` set by the middleware. Use this 'HttpContext' extension method to the current tenant to the provided `TenantInfo`. Returns true if successful. Optionally it can also reset the service provider scope so that any scoped services already resolved will - be - resolved again under the current tenant when needed. This has no effect on singleton or transient services. Setting - the `TenantInfo` with this method sets both the `StoreInfo` and `StrategyInfo` properties on - the `MultiTenantContext` to `null`. + be resolved again under the current tenant when needed. This has no effect on singleton or transient services. Setting + the `TenantInfo` with this method sets both the `StoreInfo` and `StrategyInfo` properties on the + `MultiTenantContext` to `null`. ```csharp var newTenantInfo = new TenantInfo(...); diff --git a/docs/CoreConcepts.md b/docs/CoreConcepts.md index b3750f65..2e19f4d2 100644 --- a/docs/CoreConcepts.md +++ b/docs/CoreConcepts.md @@ -29,7 +29,7 @@ when needed via the tenant `Id`. The `MultiTenantContext` contains information about the current tenant. -* Implements `IMultiTenantContext` and `IMultiTenantContext` which can be obtained from depdency injection. +* Implements `IMultiTenantContext` and `IMultiTenantContext` which can be obtained from dependency injection. * Includes `TenantInfo`, `StrategyInfo`, and `StoreInfo` properties with details on the current tenant, how it was determined, and from where its information was retrieved. * Can be obtained in ASP.NET Core by calling the `GetMultiTenantContext()` method on the current request's `HttpContext` diff --git a/docs/EFCore.md b/docs/EFCore.md index b1a3ae71..4592e29c 100644 --- a/docs/EFCore.md +++ b/docs/EFCore.md @@ -242,7 +242,7 @@ public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, } ``` -Now whenever this database context is used it will only set and query records for the current tenant. +Now whenever this database context is used, it will only set and query records for the current tenant. ## Deriving from `MultiTenantDbContext` diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index f29f57f3..56e17e54 100644 --- a/docs/GettingStarted.md +++ b/docs/GettingStarted.md @@ -16,10 +16,9 @@ $ dotnet add package Finbuckle.MultiTenant.AspNetCore ## Basic Configuration -Finbuckle.MultiTenant is simple to get started with. Below is a sample app that configured to use the subdomain as the -tenant identifier and the -app's [configuration](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/) (most likely from -a `appsettings.json` file)' as the source of tenant details. +Finbuckle.MultiTenant is simple to get started with. Below is a sample app configured to use the subdomain as the tenant +identifier and the app's [configuration](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/) ( +most likely from a`appsettings.json` file) as the source of tenant details. ```csharp using Finbuckle.MultiTenant; @@ -51,9 +50,8 @@ This line registers the base services and designates `TenantInfo` as the class t runtime. The type parameter for `AddMultiTenant` must be an implementation of `ITenantInfo` and holds basic -information about -the tenant such as its name and an identifier. `TenantInfo` is provided as a basic implementation, but a custom -implementation can be used if more properties are needed. +information about the tenant such as its name and an identifier. `TenantInfo` is provided as a basic implementation, but +a custom implementation can be used if more properties are needed. See [Core Concepts](CoreConcepts) for more information on `ITenantInfo`. @@ -78,8 +76,8 @@ ways. `app.UseMultiTenant()` This line configures the middleware which resolves the tenant using the registered strategies, stores, and other -settings. Be sure to call it before other middleware which will use per-tenant -functionality, such as `UseAuthentication()`. +settings. Be sure to call it before other middleware which will use per-tenant functionality, such as +`UseAuthentication()`. ## Basic Usage @@ -100,8 +98,8 @@ if(tenantInfo != null) The type of the `TenantInfo` property depends on the type passed when calling `AddMultiTenant` during configuration. If the current tenant could not be determined then `TenantInfo` will be null. -The `ITenantInfo` instance and the typed instance are also available using -the `IMultiTenantContextAccessor` interface which is available via dependency injection. +The `ITenantInfo` instance and the typed instance are also available using the +`IMultiTenantContextAccessor` interface which is available via dependency injection. See [Configuration and Usage](ConfigurationAndUsage) for more information. diff --git a/docs/Identity.md b/docs/Identity.md index 3583d1af..da740b29 100644 --- a/docs/Identity.md +++ b/docs/Identity.md @@ -8,7 +8,7 @@ calls into the database instead of your own code. See the Identity data isolation sample projects in the [GitHub repository](https://github.com/Finbuckle/Finbuckle.MultiTenant/tree/master/samples) for examples on how to -use Finbuckle.MultiTenant with ASP.NET Core Identity. These samples illustrates how to isolate the tenant Identity data +use Finbuckle.MultiTenant with ASP.NET Core Identity. These samples illustrate how to isolate the tenant Identity data and integrate the Identity UI to work with a route multi-tenant strategy. ## Configuration diff --git a/docs/Options.md b/docs/Options.md index 03e4b889..22397240 100644 --- a/docs/Options.md +++ b/docs/Options.md @@ -9,7 +9,7 @@ multi-tenant capability with minimal code changes. Finbuckle.MultiTenant integrates with the standard [.NET Options pattern](https://learn.microsoft.com/en-us/dotnet/core/extensions/options) (see also the [ASP.NET -Core Options pattern](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options) and lets apps +Core Options pattern](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options)) and lets apps customize options distinctly for each tenant. Note: For authentication options, Finbuckle.MultiTenant provides special support diff --git a/docs/Stores.md b/docs/Stores.md index 3a956ddf..3f82e4ac 100644 --- a/docs/Stores.md +++ b/docs/Stores.md @@ -67,8 +67,8 @@ Currently `InMemoryStore`, `ConfigurationStore`, and `EFCoreStore` implement `Ge Uses a `ConcurrentDictionary` as the underlying store. -Configure by calling `WithInMemoryStore` after `AddMultiTenant`. By default the store is empty and the -tenant identifier matching is case insensitive. Case insensitive is generally preferred. An overload +Configure by calling `WithInMemoryStore` after `AddMultiTenant`. By default, the store is empty and the +tenant identifier matching is case-insensitive. Case-insensitive is generally preferred. An overload of `WithInMemoryStore` accepts an `Action` delegate to configure the store further: ```csharp @@ -93,14 +93,14 @@ builder.Services.AddMultiTenant() Uses an app's [configuration](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/) as -the underlying store. Most of the sample projects use this store for simplicity. This store is case insensitive when +the underlying store. Most of the sample projects use this store for simplicity. This store is case-insensitive when retrieving tenant information by tenant identifier. This store is read-only and calls to `TryAddAsync`, `TryUpdateAsync`, and `TryRemoveAsync` will throw a `NotImplementedException`. However, if the app is configured to reload its configuration if the source changes, e.g. `appsettings.json` is updated, then the MultiTenant store will reflect the change. -Configure by calling `WithConfigurationStore` after `AddMultiTenant`. By default it will use the root +Configure by calling `WithConfigurationStore` after `AddMultiTenant`. By default, it will use the root configuration object and search for a section named "Finbuckle:MultiTenant:Stores:ConfigurationStore". An overload of `WithConfigurationStore` allows for a different base configuration object or section name if needed. @@ -182,7 +182,8 @@ builder.Services.AddMultiTenant() .WithEFCoreStore()... ``` -In addition the `IMultiTenantStore` interface methods, the database context can be used to modify data in the same way +In addition to the `IMultiTenantStore` interface methods, the database context can be used to modify data in the +same way Entity Framework Core works with any database context which can offer richer functionality. ## Http Remote Store @@ -193,12 +194,12 @@ Sends the tenant identifier, provided by the multitenant strategy, to an http(s) in return. The [Http Remote Store Sample](https://github.com/Finbuckle/Finbuckle.MultiTenant/tree/v6.9.1/samples/ASP.NET%20Core%203/HttpRemoteStoreSample) -projects demonstrate this store. This store is usually case insensitive when retrieving tenant information by tenant identifier, but the remote server might be more restrictive. +projects demonstrate this store. This store is usually case-insensitive when retrieving tenant information by tenant identifier, but the remote server might be more restrictive. Make sure the tenant info type will support basic JSON serialization and deserialization via `System.Text.Json`. This strategy will attempt to deserialize the tenant using the [System.Text.Json web defaults](https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-configure-options?pivots=dotnet-6-0#web-defaults-for-jsonserializeroptions). -For a successfully request, the store expects a 200 response code and a json body with properties `Id`, `Identifier` +For a successful request, the store expects a 200 response code and a json body with properties `Id`, `Identifier` , `Name`, and other properties which will be mapped into a `TenantInfo` object with the type passed to `AddMultiTenant`. @@ -253,13 +254,13 @@ implementation. A sliding expiration is also supported. The store does not inter Make sure the tenant info type will support basic JSON serialization and deserialization via `System.Text.Json`. This strategy will attempt to deserialize the tenant using the [System.Text.Json web defaults](https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-configure-options?pivots=dotnet-6-0#web-defaults-for-jsonserializeroptions). -Each tenant info instance is actually stored twice in the cache, once using the Tenant Id as the key and another using +Each tenant info instance is actually stored twice in the cache, once using the Tenant ID as the key and another using the Tenant Identifier as the key. Calls to `TryAddAsync`, `TryUpdateAsync`, and `TryRemoveAsync` will keep these dual cache entries synced. This store does not implement `GetAllAsync`. -Configure by calling `WithDistributedCacheStore` after `AddMultiTenant`. By default entries do not expire, +Configure by calling `WithDistributedCacheStore` after `AddMultiTenant`. By default, entries do not expire, but a `TimeSpan` can be passed to be used as a sliding expiration: diff --git a/docs/Strategies.md b/docs/Strategies.md index d098a3a4..473f36f9 100644 --- a/docs/Strategies.md +++ b/docs/Strategies.md @@ -45,7 +45,7 @@ type is for several instances of `DelegateStrategy` utilizing distinct logic or > NuGet package: Finbuckle.MultiTenant Always uses the same identifier to resolve the tenant. Often useful in testing or to resolve to a fallback or default -tenant by registering the strategy last. +tenant. This strategy will run last no matter where it is configured. Configure by calling `WithStaticStrategy` after `AddMultiTenant` and passing in the identifier to use for tenant resolution: @@ -126,15 +126,15 @@ builder.Services.AddMultiTenant() Be aware that relative links to static files will be impacted so css files and other static resources may need to be referenced using absolute urls. Alternatively, you can place the `UseStaticFiles` middleware after -the `UseMultiTenant` middware in the app pipeline configuration. +the `UseMultiTenant` middleware in the app pipeline configuration. ## Claim Strategy > NuGet package: Finbuckle.MultiTenant.AspNetCore -Uses a claim to determine the tenant identifier. By default the first claim value with type `__tenant__` is used, but a +Uses a claim to determine the tenant identifier. By default, the first claim value with type `__tenant__` is used, but a custom type name can also be used. This strategy uses the default authentication scheme, which is usually cookie based, -but does not go so far as to set `HttpContext.User`. Thus the ASP.NET Core authentication middleware should still be +but does not go so far as to set `HttpContext.User`. Thus, the ASP.NET Core authentication middleware should still be used as normal, and in most use cases should come after `UseMultiTenant`. Note that this strategy is does not work well with per-tenant cookie names since it must know the cookie name before the @@ -185,7 +185,7 @@ tenant without invoking the expensive strategy. Uses the `__tenant__` route parameter (or a specified route parameter) to determine the tenant. For example, a request to "https://www.example.com/initech/home/" and a route configuration of `{__tenant__}/{controller=Home}/{action=Index}` would use "initech" as the identifier when resolving the tenant. The `__tenant__` parameter can be placed anywhere in -the route path configuration. If explicity calling `UseRouting` in your app pipline make sure to place it +the route path configuration. If explicitly calling `UseRouting` in your app pipeline make sure to place it before `WithRouteStrategy`. Configure by calling `WithRouteStrategy` after `AddMultiTenant`. A custom route parameter can also be @@ -209,7 +209,7 @@ app.UseMultiTenant(); > NuGet package: Finbuckle.MultiTenant.AspNetCore -Uses request's host value to determine the tenant. By default the first host segment is used. For example, a request +Uses request's host value to determine the tenant. By default, the first host segment is used. For example, a request to "https://initech.example.com/abc123" would use "initech" as the identifier when resolving the tenant. This strategy can be difficult to use in a development environment. Make sure the development system is configured properly to allow subdomains on `localhost`. This strategy is configured as a singleton. @@ -246,7 +246,7 @@ builder.Services.AddMultiTenant() > NuGet package: Finbuckle.MultiTenant.AspNetCore -Uses an HTTP request header to determine the tenant identifier. By default the header with key `__tenant__` is used, but +Uses an HTTP request header to determine the tenant identifier. By default, the header with key `__tenant__` is used, but a custom key can also be used. Configure by calling `WithHeaderStrategy` after `AddMultiTenant`. An overload to accept a custom claim type diff --git a/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs b/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs index f3f3c510..8f7345bf 100644 --- a/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs +++ b/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs @@ -214,24 +214,24 @@ public static MultiTenantBuilder WithBasePathStrategy( where TTenantInfo : class, ITenantInfo, new() { builder.Services.Configure(configureOptions); - builder.Services.Configure(options => + builder.Services.Configure>(options => { - var origOnTenantResolved = options.Events.OnTenantResolved; - options.Events.OnTenantResolved = tenantResolvedContext => + var origOnTenantResolved = options.Events.OnTenantResolveCompleted; + options.Events.OnTenantResolveCompleted = resolutionCompletedContext => { - if (tenantResolvedContext.StrategyType == typeof(BasePathStrategy) && - tenantResolvedContext.Context is HttpContext httpContext && + if (resolutionCompletedContext.MultiTenantContext.StrategyInfo?.StrategyType == typeof(BasePathStrategy) && + resolutionCompletedContext.Context is HttpContext httpContext && httpContext.RequestServices.GetRequiredService>().Value .RebaseAspNetCorePathBase) { - httpContext.Request.Path.StartsWithSegments($"/{tenantResolvedContext.TenantInfo?.Identifier}", + httpContext.Request.Path.StartsWithSegments($"/{resolutionCompletedContext.MultiTenantContext.TenantInfo?.Identifier}", out var matched, out var newPath); httpContext.Request.PathBase = httpContext.Request.PathBase.Add(matched); httpContext.Request.Path = newPath; } - return origOnTenantResolved(tenantResolvedContext); + return origOnTenantResolved(resolutionCompletedContext); }; }); diff --git a/src/Finbuckle.MultiTenant/DependencyInjection/ServiceCollectionExtensions.cs b/src/Finbuckle.MultiTenant/DependencyInjection/ServiceCollectionExtensions.cs index 315348dd..d3ec3598 100644 --- a/src/Finbuckle.MultiTenant/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Finbuckle.MultiTenant/DependencyInjection/ServiceCollectionExtensions.cs @@ -26,7 +26,7 @@ public static class FinbuckleServiceCollectionExtensions /// A new instance of MultiTenantBuilder. // ReSharper disable once MemberCanBePrivate.Global public static MultiTenantBuilder AddMultiTenant(this IServiceCollection services, - Action config) + Action> config) where TTenantInfo : class, ITenantInfo, new() { services.AddScoped, TenantResolver>(); @@ -41,7 +41,7 @@ public static MultiTenantBuilder AddMultiTenant(this I services.AddSingleton(sp => (IMultiTenantContextSetter)sp.GetRequiredService()); - services.Configure(options => options.TenantInfoType = typeof(TTenantInfo)); + services.Configure>(options => options.TenantInfoType = typeof(TTenantInfo)); services.Configure(config); return new MultiTenantBuilder(services); diff --git a/src/Finbuckle.MultiTenant/Events/MultiTenantEvents.cs b/src/Finbuckle.MultiTenant/Events/MultiTenantEvents.cs index 02529574..389cc75e 100644 --- a/src/Finbuckle.MultiTenant/Events/MultiTenantEvents.cs +++ b/src/Finbuckle.MultiTenant/Events/MultiTenantEvents.cs @@ -1,21 +1,29 @@ // Copyright Finbuckle LLC, Andrew White, and Contributors. // Refer to the solution LICENSE file for more information. +using Finbuckle.MultiTenant.Abstractions; + namespace Finbuckle.MultiTenant.Events; /// /// Events for successful and failed tenant resolution. /// -public class MultiTenantEvents +/// The ITenantInfo implementation type. +public class MultiTenantEvents + where TTenantInfo : class, ITenantInfo, new() { /// - /// Called when a tenant is successfully resolved. + /// Called after each MultiTenantStrategy has run. The resulting identifier can be modified if desired or set to null to advance to the next strategy. /// - public Func OnTenantResolved { get; set; } = context => Task.CompletedTask; - + public Func OnStrategyResolveCompleted { get; set; } = context => Task.CompletedTask; + + /// + /// Called after each MultiTenantStore has attempted to find the tenant identifier. The resulting TenantInfo can be modified if desired or set to null to advance to the next store. + /// + public Func, Task> OnStoreResolveCompleted { get; set; } = context => Task.CompletedTask; /// - /// Called when no tenant fails is successfully resolved. + /// Called after tenant resolution has completed for all strategies and stores. The resulting MultiTenantContext can be modified if desired. /// - public Func OnTenantNotResolved { get; set; } = context => Task.CompletedTask; + public Func, Task> OnTenantResolveCompleted { get; set; } = context => Task.CompletedTask; } \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/Events/StoreResolveCompletedContext.cs b/src/Finbuckle.MultiTenant/Events/StoreResolveCompletedContext.cs new file mode 100644 index 00000000..6db25560 --- /dev/null +++ b/src/Finbuckle.MultiTenant/Events/StoreResolveCompletedContext.cs @@ -0,0 +1,34 @@ +// Copyright Finbuckle LLC, Andrew White, and Contributors. +// Refer to the solution LICENSE file for more information. + +using Finbuckle.MultiTenant.Abstractions; + +namespace Finbuckle.MultiTenant.Events; + +/// +/// Context for when a MultiTenantStore has attempted to look up a tenant identifier. +/// +/// The ITenantInfo implementation type. +public class StoreResolveCompletedContext + where TTenantInfo : class, ITenantInfo, new() +{ + /// + /// The MultiTenantStore instance that was run. + /// + public IMultiTenantStore Store { get; init; } + + /// + /// The identifier used for tenant resolution by the store. + /// + public required string Identifier { get; init; } + + /// + /// The resolved TenantInfo. Setting to null will cause the next store to run + /// + public TTenantInfo? TenantInfo { get; set; } + + /// + /// Returns true if a tenant was found. + /// + public bool TenantFound => TenantInfo != null; +} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/Events/StrategyResolveCompletedContext.cs b/src/Finbuckle.MultiTenant/Events/StrategyResolveCompletedContext.cs new file mode 100644 index 00000000..3e248281 --- /dev/null +++ b/src/Finbuckle.MultiTenant/Events/StrategyResolveCompletedContext.cs @@ -0,0 +1,32 @@ +// Copyright Finbuckle LLC, Andrew White, and Contributors. +// Refer to the solution LICENSE file for more information. + +using Finbuckle.MultiTenant.Abstractions; + +namespace Finbuckle.MultiTenant.Events; + +/// +/// Context for when a MultiTenantStrategy has run. +/// +public class StrategyResolveCompletedContext +{ + /// + /// Gets or sets the context used for attempted tenant resolution. + /// + public object? Context { get; set; } + + /// + /// The MultiTenantStrategy instance that was run. + /// + public required IMultiTenantStrategy Strategy { get; init; } + + /// + /// Gets or sets the identifier found by the strategy. Setting to null will cause the next strategy to run. + /// + public string? Identifier { get; set; } + + /// + /// Returns true if a tenant identifier was found. + /// + public bool IdentifierFound => Identifier != null; +} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/Events/TenantNotFoundContext.cs b/src/Finbuckle.MultiTenant/Events/TenantNotFoundContext.cs deleted file mode 100644 index d60fdd46..00000000 --- a/src/Finbuckle.MultiTenant/Events/TenantNotFoundContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright Finbuckle LLC, Andrew White, and Contributors. -// Refer to the solution LICENSE file for more information. - -namespace Finbuckle.MultiTenant.Events; - -/// -/// Context for when a tenant is not resolved. -/// -public class TenantNotResolvedContext -{ - /// - /// Gets or sets the context used for attempted tenant resolution. - /// - public object? Context { get; set; } - - - /// - /// Gets or sets the last identifier used for attempted tenant resolution. - /// - public string? Identifier { get; set; } -} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/Events/TenantResolveCompletedContext.cs b/src/Finbuckle.MultiTenant/Events/TenantResolveCompletedContext.cs new file mode 100644 index 00000000..092608b4 --- /dev/null +++ b/src/Finbuckle.MultiTenant/Events/TenantResolveCompletedContext.cs @@ -0,0 +1,26 @@ +using Finbuckle.MultiTenant.Abstractions; + +namespace Finbuckle.MultiTenant.Events; + +/// +/// Context for when tenant resolution has completed. +/// +/// The ITenantInfo implementation type. +public record TenantResolveCompletedContext + where TTenantInfo : class, ITenantInfo, new() +{ + /// + /// The resolved MultiTenantContext. + /// + public required MultiTenantContext MultiTenantContext { get; set; } + + /// + /// The context used to resolve the tenant. + /// + public required object Context { get; init; } + + /// + /// + /// + public bool IsResolved => MultiTenantContext.IsResolved; +} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/Events/TenantResolvedContext.cs b/src/Finbuckle.MultiTenant/Events/TenantResolvedContext.cs deleted file mode 100644 index ee36ddb2..00000000 --- a/src/Finbuckle.MultiTenant/Events/TenantResolvedContext.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright Finbuckle LLC, Andrew White, and Contributors. -// Refer to the solution LICENSE file for more information. - -using Finbuckle.MultiTenant.Abstractions; - -namespace Finbuckle.MultiTenant.Events; - -// TODO consider making these setters private - -/// -/// Context for when a tenant is successfully resolved. -/// -public class TenantResolvedContext -{ - /// - /// Gets or sets the context used for tenant resolution. - /// - public object? Context { get; set; } - - - /// - /// Gets or sets the resolved TenantInfo. - /// - // TODO probably shouldn't be nullable? - public ITenantInfo? TenantInfo { get; set; } - - - /// - /// Gets or sets the type of the multitenant strategy which resolved the tenant. - /// - public Type? StrategyType { get; set; } - - - /// - /// Gets or sets the type of the multitenant store which resolved the tenant. - /// - public Type? StoreType { get; set; } - // TODO consider refactoring to just MultiTenantContext -} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/MultiTenantOptions.cs b/src/Finbuckle.MultiTenant/MultiTenantOptions.cs index 82a98354..7a5cc681 100644 --- a/src/Finbuckle.MultiTenant/MultiTenantOptions.cs +++ b/src/Finbuckle.MultiTenant/MultiTenantOptions.cs @@ -1,15 +1,18 @@ // Copyright Finbuckle LLC, Andrew White, and Contributors. // Refer to the solution LICENSE file for more information. -// TODO move to options folder/namespace on future major release - +using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.Events; namespace Finbuckle.MultiTenant; -public class MultiTenantOptions +/// +/// Options for multitenant resolution. +/// +/// The ITenantInfo implementation type.X +public class MultiTenantOptions where TTenantInfo : class, ITenantInfo, new() { public Type? TenantInfoType { get; internal set; } public IList IgnoredIdentifiers { get; set; } = new List(); - public MultiTenantEvents Events { get; set; } = new (); + public MultiTenantEvents Events { get; set; } = new (); } \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant/Strategies/StaticStrategy.cs b/src/Finbuckle.MultiTenant/Strategies/StaticStrategy.cs index 56f7ad0a..a30b023a 100644 --- a/src/Finbuckle.MultiTenant/Strategies/StaticStrategy.cs +++ b/src/Finbuckle.MultiTenant/Strategies/StaticStrategy.cs @@ -7,9 +7,11 @@ namespace Finbuckle.MultiTenant.Strategies; public class StaticStrategy : IMultiTenantStrategy { + // internal for testing + // ReSharper disable once MemberCanBePrivate.Global internal readonly string Identifier; - public int Priority { get => -1000; } + public int Priority => -1000; public StaticStrategy(string identifier) { diff --git a/src/Finbuckle.MultiTenant/TenantResolver.cs b/src/Finbuckle.MultiTenant/TenantResolver.cs index 4329bcf1..f9122096 100644 --- a/src/Finbuckle.MultiTenant/TenantResolver.cs +++ b/src/Finbuckle.MultiTenant/TenantResolver.cs @@ -14,21 +14,21 @@ namespace Finbuckle.MultiTenant; /// /// Resolves the current tenant. /// -/// The ITenantInfo implementation type. +/// The ITenantInfo implementation type.X public class TenantResolver : ITenantResolver where TTenantInfo : class, ITenantInfo, new() { - private readonly IOptionsMonitor options; + private readonly IOptionsMonitor> options; private readonly ILoggerFactory? loggerFactory; public TenantResolver(IEnumerable strategies, - IEnumerable> stores, IOptionsMonitor options) : + IEnumerable> stores, IOptionsMonitor> options) : this(strategies, stores, options, null) { } public TenantResolver(IEnumerable strategies, - IEnumerable> stores, IOptionsMonitor options, + IEnumerable> stores, IOptionsMonitor> options, ILoggerFactory? loggerFactory) { Stores = stores; @@ -48,18 +48,25 @@ public TenantResolver(IEnumerable strategies, public async Task> ResolveAsync(object context) { var mtc = new MultiTenantContext(); + var tenantResoloverLogger = loggerFactory?.CreateLogger(this.GetType()) ?? NullLogger.Instance; string? identifier = null; foreach (var strategy in Strategies) { - var wrappedStrategy = new MultiTenantStrategyWrapper(strategy, - loggerFactory?.CreateLogger(strategy.GetType()) ?? NullLogger.Instance); + var strategyLogger = loggerFactory?.CreateLogger(strategy.GetType()) ?? NullLogger.Instance; + + var wrappedStrategy = new MultiTenantStrategyWrapper(strategy, strategyLogger); identifier = await wrappedStrategy.GetIdentifierAsync(context); - + + var strategyResolveCompletedContext = new StrategyResolveCompletedContext { Context = context, Strategy = strategy, Identifier = identifier }; + await options.CurrentValue.Events.OnStrategyResolveCompleted(strategyResolveCompletedContext); + if(identifier is not null && strategyResolveCompletedContext.Identifier is null) + tenantResoloverLogger.LogDebug("OnStrategyResolveCompleted set non-null Identifier to null"); + identifier = strategyResolveCompletedContext.Identifier; + if (options.CurrentValue.IgnoredIdentifiers.Contains(identifier, StringComparer.OrdinalIgnoreCase)) { - (loggerFactory?.CreateLogger(GetType()) ?? NullLogger.Instance).LogInformation( - "Ignored identifier: {Identifier}", identifier); + tenantResoloverLogger.LogDebug("Ignored identifier: {Identifier}", identifier); identifier = null; } @@ -68,35 +75,42 @@ public async Task> ResolveAsync(object context) foreach (var store in Stores) { - var wrappedStore = new MultiTenantStoreWrapper(store, - loggerFactory?.CreateLogger(store.GetType()) ?? NullLogger.Instance); + var storeLogger = loggerFactory?.CreateLogger(store.GetType()) ?? NullLogger.Instance; + + var wrappedStore = new MultiTenantStoreWrapper(store, storeLogger); var tenantInfo = await wrappedStore.TryGetByIdentifierAsync(identifier); - if (tenantInfo == null) - continue; - - await options.CurrentValue.Events.OnTenantResolved(new TenantResolvedContext + + var storeResolveCompletedContext = new StoreResolveCompletedContext { Store = store, Identifier = identifier, TenantInfo = tenantInfo }; + await options.CurrentValue.Events.OnStoreResolveCompleted(storeResolveCompletedContext); + if(tenantInfo is not null && storeResolveCompletedContext.TenantInfo is null) + tenantResoloverLogger.LogDebug("OnStoreResolveCompleted set non-null TenantInfo to null"); + tenantInfo = storeResolveCompletedContext.TenantInfo; + + if (tenantInfo != null) { - Context = context, - TenantInfo = tenantInfo, - StrategyType = strategy.GetType(), - StoreType = store.GetType() - }); - - mtc.StoreInfo = new StoreInfo { Store = store, StoreType = store.GetType() }; - mtc.StrategyInfo = new StrategyInfo { Strategy = strategy, StrategyType = strategy.GetType() }; - mtc.TenantInfo = tenantInfo; - return mtc; + mtc.StoreInfo = new StoreInfo { Store = store, StoreType = store.GetType() }; + mtc.StrategyInfo = new StrategyInfo { Strategy = strategy, StrategyType = strategy.GetType() }; + mtc.TenantInfo = tenantInfo; + } + + // no longer check stores if tenant is resolved + if(mtc.IsResolved) + break; } + + // no longer check strategies if tenant is resolved + if(mtc.IsResolved) + break; } - await options.CurrentValue.Events.OnTenantNotResolved(new TenantNotResolvedContext - { Context = context, Identifier = identifier }); - return mtc; + var resolutionCompletedContext = new TenantResolveCompletedContext{ MultiTenantContext = mtc, Context = context }; + await options.CurrentValue.Events.OnTenantResolveCompleted(resolutionCompletedContext); + return resolutionCompletedContext.MultiTenantContext; } /// async Task ITenantResolver.ResolveAsync(object context) { - return (IMultiTenantContext)(await ResolveAsync(context)); + return await ResolveAsync(context); } } \ No newline at end of file diff --git a/test/Finbuckle.MultiTenant.Test/DependencyInjection/ServiceCollectionExtensionsShould.cs b/test/Finbuckle.MultiTenant.Test/DependencyInjection/ServiceCollectionExtensionsShould.cs index b2d08147..18fd2b3b 100644 --- a/test/Finbuckle.MultiTenant.Test/DependencyInjection/ServiceCollectionExtensionsShould.cs +++ b/test/Finbuckle.MultiTenant.Test/DependencyInjection/ServiceCollectionExtensionsShould.cs @@ -68,7 +68,7 @@ public void RegisterMultiTenantOptionsInDi() services.AddMultiTenant(); var service = services.FirstOrDefault(s => s.Lifetime == ServiceLifetime.Singleton && - s.ServiceType == typeof(IConfigureOptions)); + s.ServiceType == typeof(IConfigureOptions>)); Assert.NotNull(service); Assert.Equal(ServiceLifetime.Singleton, service.Lifetime); diff --git a/test/Finbuckle.MultiTenant.Test/TenantResolverShould.cs b/test/Finbuckle.MultiTenant.Test/TenantResolverShould.cs index 58d8fce3..63489aa4 100644 --- a/test/Finbuckle.MultiTenant.Test/TenantResolverShould.cs +++ b/test/Finbuckle.MultiTenant.Test/TenantResolverShould.cs @@ -126,9 +126,9 @@ await sp.GetServices>() } [Fact] - public async Task CallOnTenantResolvedEventIfSuccess() + public async Task CallOnTenantResolveCompletedIfSuccess() { - TenantResolvedContext? resolvedContext = null; + TenantResolveCompletedContext? resolvedContext = null; var configuration = new ConfigurationBuilder() .AddJsonFile("ConfigurationStoreTestSettings.json") @@ -136,10 +136,9 @@ public async Task CallOnTenantResolvedEventIfSuccess() var services = new ServiceCollection(); services.AddSingleton(configuration); - services.Configure(options => - options.Events.OnTenantResolved = context => Task.FromResult(resolvedContext = context)); + services.Configure>(options => + options.Events.OnTenantResolveCompleted = context => Task.FromResult(resolvedContext = context)); services.AddMultiTenant() - .WithDelegateStrategy(_ => Task.FromResult("not-found")) .WithStaticStrategy("initech") .WithConfigurationStore(); var sp = services.BuildServiceProvider(); @@ -148,45 +147,83 @@ public async Task CallOnTenantResolvedEventIfSuccess() await resolver.ResolveAsync(new object()); Assert.NotNull(resolvedContext); - Assert.Equal("initech", resolvedContext.TenantInfo!.Identifier); - Assert.Equal(typeof(StaticStrategy), resolvedContext.StrategyType); - Assert.Equal(typeof(ConfigurationStore), resolvedContext.StoreType); + Assert.Equal("initech", resolvedContext.MultiTenantContext.TenantInfo!.Identifier); + Assert.Equal(typeof(StaticStrategy), resolvedContext.MultiTenantContext.StrategyInfo!.StrategyType); + Assert.Equal(typeof(ConfigurationStore), resolvedContext.MultiTenantContext.StoreInfo!.StoreType); } - + [Fact] - public async Task CallOnTenantNotResolvedEventIfNoStrategySuccess() + public async Task CallOnTenantResolveCompletedIfFailure() { - TenantNotResolvedContext? notResolvedContext = null; + TenantResolveCompletedContext? resolvedContext = null; + + var configuration = new ConfigurationBuilder() + .AddJsonFile("ConfigurationStoreTestSettings.json") + .Build(); var services = new ServiceCollection(); - services.Configure(options => - options.Events.OnTenantNotResolved = context => Task.FromResult(notResolvedContext = context)); - services - .AddMultiTenant() - .WithDelegateStrategy(_ => Task.FromResult(null!)); + services.AddSingleton(configuration); + services.Configure>(options => + options.Events.OnTenantResolveCompleted = context => Task.FromResult(resolvedContext = context)); + services.AddMultiTenant() + .WithDelegateStrategy(_ => Task.FromResult("not-found")) + .WithConfigurationStore(); var sp = services.BuildServiceProvider(); var resolver = sp.GetRequiredService>(); await resolver.ResolveAsync(new object()); - Assert.NotNull(notResolvedContext); + Assert.NotNull(resolvedContext); + Assert.False(resolvedContext.IsResolved); } + + [Fact] + public async Task CallOnStrategyResolveCompletedPerStrategy() + { + var numCalls = 0; + + var configuration = new ConfigurationBuilder() + .AddJsonFile("ConfigurationStoreTestSettings.json") + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.Configure>(options => + options.Events.OnStrategyResolveCompleted = context => Task.FromResult(numCalls++)); + services.AddMultiTenant() + .WithDelegateStrategy(_ => Task.FromResult("not-found")) + .WithStaticStrategy("initech") + .WithConfigurationStore(); + var sp = services.BuildServiceProvider(); + var resolver = sp.GetRequiredService>(); + await resolver.ResolveAsync(new object()); + + Assert.Equal(2, numCalls); + } + [Fact] - public async Task CallOnTenantNotResolvedEventIfNoStoreSuccess() + public async Task CallOnStoreResolveCompletedPerStore() { - TenantNotResolvedContext? notResolvedContext = null; + var numCalls = 0; + + var configuration = new ConfigurationBuilder() + .AddJsonFile("ConfigurationStoreTestSettings.json") + .Build(); + var services = new ServiceCollection(); - services.Configure(options => - options.Events.OnTenantNotResolved = context => Task.FromResult(notResolvedContext = context)); + services.AddSingleton(configuration); + services.Configure>(options => + options.Events.OnStoreResolveCompleted = context => Task.FromResult(numCalls++)); services.AddMultiTenant() - .WithStaticStrategy("not-found") - .WithInMemoryStore(); + .WithStaticStrategy("initech") + .WithInMemoryStore() + .WithConfigurationStore(); var sp = services.BuildServiceProvider(); var resolver = sp.GetRequiredService>(); await resolver.ResolveAsync(new object()); - Assert.NotNull(notResolvedContext); + Assert.Equal(2, numCalls); } } \ No newline at end of file