diff --git a/docs/ConfigurationAndUsage.md b/docs/ConfigurationAndUsage.md index 468f6144..fbda052f 100644 --- a/docs/ConfigurationAndUsage.md +++ b/docs/ConfigurationAndUsage.md @@ -109,6 +109,108 @@ The `TenantResolver` options are configured in the `AddMultiTenant` contains the resolved multi-tenant context and can be changed by the event handler to override the resolver's result. +## ASP.NET Core Features + +Some additional features are avaiable to tailor the middleware to your specific needs. + +### Exclude Endpoints From Tenant Resolution + +If you have scenarios where you do not want Tenant resolution to be performed, you can exclude specific endpoints. + +Using the `IEndpointConventionBuilder` extension `ExcludeFromMultiTenantResolution`: + +```csharp +var app = builder.Build(); + +// Exclude OpenApi endpoints. +app.MapOpenApi() + .ExcludeFromMultiTenantResolution(); + +// Exclude a specific endpoint. +app.MapGet("/oops", () => "Oops! An error happened.") + .ExcludeFromMultiTenantResolution(); + +// Exclude a group of endpoints. +app.MapGroup("api/v{version:apiVersion}/dashboard") + .ExcludeFromMultiTenantResolution(); + +// Exclude static asset endpoints. +app.MapStaticAssets() + .ExcludeFromMultiTenantResolution(); + +app.Run(); +``` + +Using the `ExcludeFromMultiTenantResolutionAttribute` attribute to configure the behavior of controllers and action methods. + +```csharp +// Here --> [ExcludeFromMultiTenantResolution] +public class DashboardController : Controller +{ + // Or here --> [ExcludeFromMultiTenantResolution] + public ActionResult Index() + { + return View(); + } +} +``` + +### Short Curcuiting + +If you would like to short circuit for any reason, ending the request handling process early from within the `MultiTenantMiddleware`. +The `ShortCircuitWhenTenantNotResolved()` methods can be called after +`AddMultiTenant()`. + +DISCLAIMER: If you short circuit when tenant not resolved, and you have endpoints that do not require a tenant, +then `ExcludeFromMultiTenantResolution` becomes a necessity, otherwise, these endpoints would never be reached. + +## Short Circuit When Tenant Not resolved + +`MultiTenantOptions` provides convenient events, such as `OnTenantResolveCompleted`, giving you the flexibility to take action during resolution +when no tenant was found. However, these events have no inherit way to direct the `MultiTenantMiddleware` to take any specific actions. +In the case you want to short circuit when a tenant is not found, use the `MultiTenantBuilder` extension +`ShortCircuitWhenTenantNotResolved()`. + +```csharp +// Simply short circuit the request, ending request handling. +builder.Services.AddMultiTenant() + .WithHostStrategy() + .WithConfigurationStore() + .ShortCircuitWhenTenantNotResolved(); + +// Short circuit and redirect to a specific Uri. +builder.Services.AddMultiTenant() + .WithHostStrategy() + .WithConfigurationStore() + .ShortCircuitWhenTenantNotResolved(new Uri("/tenant/notfound", UriKind.Relative)); +``` + +## Short Circuit When + +If you find that you need to short circuit for other, more advanced reasons, +use the `MultiTenantBuilder` extension `ShortCircuitWhen()`. + +```csharp +// Advanced short circuiting, if an obsolete strategy was used or when tenant not resolved. +builder.Services.AddMultiTenant() + .WithHostStrategy() + .WithConfigurationStore() + .ShortCircuitWhen(config => + { + config.Predicate = context => context.StrategyInfo is IMyCustomObsoleteStrategy || !context.IsResolved; + }); + +// Including a redirect. +builder.Services.AddMultiTenant() + .WithHostStrategy() + .WithConfigurationStore() + .ShortCircuitWhen(config => + { + config.Predicate = context => context.StrategyInfo is IMyCustomObsoleteStrategy || !context.IsResolved; + config.RedirectTo = new Uri("/tenant/notfound", UriKind.Relative) + }); +``` + ## Getting the Current Tenant There are several ways an app can see the current tenant: diff --git a/src/Finbuckle.MultiTenant.AspNetCore/Extensions/EndpointConventionBuilderExtensions.cs b/src/Finbuckle.MultiTenant.AspNetCore/Extensions/EndpointConventionBuilderExtensions.cs new file mode 100644 index 00000000..a526d1d8 --- /dev/null +++ b/src/Finbuckle.MultiTenant.AspNetCore/Extensions/EndpointConventionBuilderExtensions.cs @@ -0,0 +1,27 @@ +using Finbuckle.MultiTenant.AspNetCore.Routing; +using Microsoft.AspNetCore.Builder; + +namespace Finbuckle.MultiTenant.AspNetCore.Extensions; + +public static class EndpointConventionBuilderExtensions +{ + private static readonly ExcludeFromMultiTenantResolutionAttribute s_excludeFromMultiTenantResolutionAttribute = new(); + + /// + /// Adds the to + /// for all endpoints produced by the . + /// + /// The . + /// A that can be used to further customize the endpoint. + public static TBuilder ExcludeFromMultiTenantResolution(this TBuilder builder) where TBuilder : IEndpointConventionBuilder + => builder.WithMetadata(s_excludeFromMultiTenantResolutionAttribute); + + /// + /// Adds the to + /// for all endpoints produced by the . + /// + /// The . + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder ExcludeFromMultiTenantResolution(this RouteHandlerBuilder builder) + => ExcludeFromMultiTenantResolution(builder); +} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs b/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs index b69aa43a..d4635c60 100644 --- a/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs +++ b/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs @@ -23,6 +23,59 @@ namespace Finbuckle.MultiTenant; /// public static class MultiTenantBuilderExtensions { + /// + /// Configures a callback that determines when endpoints should be short circuited + /// during MultiTenant resolution. + /// + /// The ITenantInfo implementation type. + /// The instance. + /// The short circuit options to configure. + /// The so that additional calls can be chained. + public static MultiTenantBuilder ShortCircuitWhen( + this MultiTenantBuilder builder, Action configureOptions) + where TTenantInfo : class, ITenantInfo, new() + { + builder.Services.Configure(configureOptions); + + return builder; + } + + /// + /// Configures endpoints to be short circuited during MultiTenant resolution when + /// no Tenant was resolved. + /// + /// The ITenantInfo implementation type. + /// The instance. + /// The so that additional calls can be chained. + public static MultiTenantBuilder ShortCircuitWhenTenantNotResolved( + this MultiTenantBuilder builder) + where TTenantInfo : class, ITenantInfo, new() + { + return builder.ShortCircuitWhen(config => + { + config.Predicate = context => !context.IsResolved; + }); + } + + /// + /// Configures endpoints to be short circuited during MultiTenant resolution when + /// no Tenant was resolved. + /// + /// The ITenantInfo implementation type. + /// The instance. + /// A to redirect the request to, if short circuited. + /// The so that additional calls can be chained. + public static MultiTenantBuilder ShortCircuitWhenTenantNotResolved( + this MultiTenantBuilder builder, Uri redirectTo) + where TTenantInfo : class, ITenantInfo, new() + { + return builder.ShortCircuitWhen(config => + { + config.Predicate = context => !context.IsResolved; + config.RedirectTo = redirectTo; + }); + } + /// /// Configures authentication options to enable per-tenant behavior. /// diff --git a/src/Finbuckle.MultiTenant.AspNetCore/Internal/MultiTenantMiddleware.cs b/src/Finbuckle.MultiTenant.AspNetCore/Internal/MultiTenantMiddleware.cs index 452eaea1..6b308f63 100644 --- a/src/Finbuckle.MultiTenant.AspNetCore/Internal/MultiTenantMiddleware.cs +++ b/src/Finbuckle.MultiTenant.AspNetCore/Internal/MultiTenantMiddleware.cs @@ -2,8 +2,11 @@ // Refer to the solution LICENSE file for more information. using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.AspNetCore.Options; +using Finbuckle.MultiTenant.AspNetCore.Routing; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Finbuckle.MultiTenant.AspNetCore.Internal; @@ -13,14 +16,27 @@ namespace Finbuckle.MultiTenant.AspNetCore.Internal; public class MultiTenantMiddleware { private readonly RequestDelegate next; + private readonly ShortCircuitWhenOptions? options; public MultiTenantMiddleware(RequestDelegate next) { this.next = next; } + public MultiTenantMiddleware(RequestDelegate next, IOptions options) + { + this.next = next; + this.options = options.Value; + } + public async Task Invoke(HttpContext context) { + if (context.GetEndpoint()?.Metadata.GetMetadata() is { ExcludeFromResolution: true }) + { + await next(context); + return; + } + context.RequestServices.GetRequiredService(); var mtcSetter = context.RequestServices.GetRequiredService(); @@ -29,7 +45,10 @@ public async Task Invoke(HttpContext context) var multiTenantContext = await resolver.ResolveAsync(context).ConfigureAwait(false); mtcSetter.MultiTenantContext = multiTenantContext; context.Items[typeof(IMultiTenantContext)] = multiTenantContext; - - await next(context).ConfigureAwait(false); - } + + if (options?.Predicate is null || !options.Predicate(multiTenantContext)) + await next(context); + else if (options.RedirectTo is not null) + context.Response.Redirect(options.RedirectTo.ToString()); + } } \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant.AspNetCore/Options/ShortCircuitWhenOptions.cs b/src/Finbuckle.MultiTenant.AspNetCore/Options/ShortCircuitWhenOptions.cs new file mode 100644 index 00000000..2905af8a --- /dev/null +++ b/src/Finbuckle.MultiTenant.AspNetCore/Options/ShortCircuitWhenOptions.cs @@ -0,0 +1,30 @@ +using Finbuckle.MultiTenant.Abstractions; + +namespace Finbuckle.MultiTenant.AspNetCore.Options; + +public class ShortCircuitWhenOptions +{ + private Func? _predicate; + + /// + /// The callback that determines if the endpoint should be short circuited. + /// + public Func? Predicate + { + get + { + return _predicate; + } + set + { + ArgumentNullException.ThrowIfNull(value); + + _predicate = value; + } + } + + /// + /// A to redirect the request to, if short circuited. + /// + public Uri? RedirectTo { get; set; } +} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant.AspNetCore/Routing/ExcludeFromMultiTenantResolutionAttribute.cs b/src/Finbuckle.MultiTenant.AspNetCore/Routing/ExcludeFromMultiTenantResolutionAttribute.cs new file mode 100644 index 00000000..f8202b0f --- /dev/null +++ b/src/Finbuckle.MultiTenant.AspNetCore/Routing/ExcludeFromMultiTenantResolutionAttribute.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; + +namespace Finbuckle.MultiTenant.AspNetCore.Routing; + +/// +/// Indicates that this should be excluded from MultiTenant resolution. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Delegate, AllowMultiple = false, Inherited = true)] +public class ExcludeFromMultiTenantResolutionAttribute : Attribute, IExcludeFromMultiTenantResolutionMetadata +{ + /// + public bool ExcludeFromResolution => true; +} \ No newline at end of file diff --git a/src/Finbuckle.MultiTenant.AspNetCore/Routing/IExcludeFromMultiTenantResolutionMetadata.cs b/src/Finbuckle.MultiTenant.AspNetCore/Routing/IExcludeFromMultiTenantResolutionMetadata.cs new file mode 100644 index 00000000..6a519e10 --- /dev/null +++ b/src/Finbuckle.MultiTenant.AspNetCore/Routing/IExcludeFromMultiTenantResolutionMetadata.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; + +namespace Finbuckle.MultiTenant.AspNetCore.Routing; + +/// +/// Indicates whether MultiTenant resolution should occur for this . +/// +public interface IExcludeFromMultiTenantResolutionMetadata +{ + /// + /// Gets a value indicating whether MultiTenant resolution should + /// occur for this . If , + /// tenant resolution will not be executed. + /// + bool ExcludeFromResolution { get; } +} \ No newline at end of file diff --git a/test/Finbuckle.MultiTenant.AspNetCore.Test/MultiTenantMiddlewareShould.cs b/test/Finbuckle.MultiTenant.AspNetCore.Test/MultiTenantMiddlewareShould.cs index 9a47652f..c4548db4 100644 --- a/test/Finbuckle.MultiTenant.AspNetCore.Test/MultiTenantMiddlewareShould.cs +++ b/test/Finbuckle.MultiTenant.AspNetCore.Test/MultiTenantMiddlewareShould.cs @@ -3,8 +3,11 @@ using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.AspNetCore.Internal; +using Finbuckle.MultiTenant.AspNetCore.Options; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Moq; using Xunit; @@ -25,6 +28,7 @@ public async Task SetHttpContextItemIfTenantFound() var context = new Mock(); context.Setup(c => c.RequestServices).Returns(sp); + context.Setup(c => c.Features).Returns(new FeatureCollection()); var itemsDict = new Dictionary(); context.Setup(c => c.Items).Returns(itemsDict); @@ -39,6 +43,48 @@ public async Task SetHttpContextItemIfTenantFound() Assert.Equal("initech", mtc.TenantInfo.Id); } + [Fact] + public async Task NotShortCircuitIfTenantFound() + { + var services = new ServiceCollection(); + services.AddMultiTenant(). + WithStaticStrategy("initech"). + WithInMemoryStore(); + var sp = services.BuildServiceProvider(); + var store = sp.GetRequiredService>(); + await store.TryAddAsync(new TenantInfo { Id = "initech", Identifier = "initech" }); + + var context = new Mock(); + context.Setup(c => c.RequestServices).Returns(sp); + context.Setup(c => c.Features).Returns(new FeatureCollection()); + var response = new Mock(); + context.Setup(c => c.Response).Returns(response.Object); + + var itemsDict = new Dictionary(); + context.Setup(c => c.Items).Returns(itemsDict); + + var options = new ShortCircuitWhenOptions { Predicate = context => !context.IsResolved }; + var optionsMock = new Mock>(); + optionsMock.Setup(c => c.Value).Returns(options); + + var calledNext = false; + var mw = new MultiTenantMiddleware(_ => + { + calledNext = true; + + return Task.CompletedTask; + }, optionsMock.Object); + + await mw.Invoke(context.Object); + + var mtc = (IMultiTenantContext?)context.Object.Items[typeof(IMultiTenantContext)]; + + Assert.NotNull(mtc?.TenantInfo); + Assert.Equal("initech", mtc.TenantInfo.Id); + Assert.True(calledNext); + response.Verify(r => r.Redirect("/tenant/notfound"), Times.Never); + } + [Fact] public async Task SetTenantAccessor() { @@ -52,7 +98,8 @@ public async Task SetTenantAccessor() var context = new Mock(); context.Setup(c => c.RequestServices).Returns(sp); - + context.Setup(c => c.Features).Returns(new FeatureCollection()); + var itemsDict = new Dictionary(); context.Setup(c => c.Items).Returns(itemsDict); @@ -87,7 +134,8 @@ public async Task NotSetTenantAccessorIfNoTenant() var context = new Mock(); context.Setup(c => c.RequestServices).Returns(sp); - + context.Setup(c => c.Features).Returns(new FeatureCollection()); + var itemsDict = new Dictionary(); context.Setup(c => c.Items).Returns(itemsDict); @@ -108,4 +156,94 @@ public async Task NotSetTenantAccessorIfNoTenant() Assert.False(mtc.IsResolved); Assert.Null(mtc.TenantInfo); } + + [Fact] + public async Task ShortCircuitIfNoTenant() + { + var services = new ServiceCollection(); + services.AddMultiTenant(). + WithStaticStrategy("not_initech"). + WithInMemoryStore(); + var sp = services.BuildServiceProvider(); + var store = sp.GetRequiredService>(); + await store.TryAddAsync(new TenantInfo { Id = "initech", Identifier = "initech" }); + + var context = new Mock(); + context.Setup(c => c.RequestServices).Returns(sp); + context.Setup(c => c.Features).Returns(new FeatureCollection()); + var response = new Mock(); + context.Setup(c => c.Response).Returns(response.Object); + + var itemsDict = new Dictionary(); + context.Setup(c => c.Items).Returns(itemsDict); + + var options = new ShortCircuitWhenOptions { Predicate = context => !context.IsResolved }; + var optionsMock = new Mock>(); + optionsMock.Setup(c => c.Value).Returns(options); + + var calledNext = false; + var mw = new MultiTenantMiddleware(_ => + { + calledNext = true; + + return Task.CompletedTask; + }, optionsMock.Object); + + await mw.Invoke(context.Object); + + var mtc = (IMultiTenantContext?)context.Object.Items[typeof(IMultiTenantContext)]; + + Assert.NotNull(mtc); + Assert.False(mtc.IsResolved); + Assert.Null(mtc.TenantInfo); + Assert.False(calledNext); + response.Verify(r => r.Redirect("/tenant/notfound"), Times.Never); + } + + [Fact] + public async Task ShortCircuitAndRedirectIfNoTenant() + { + var services = new ServiceCollection(); + services.AddMultiTenant(). + WithStaticStrategy("not_initech"). + WithInMemoryStore(); + var sp = services.BuildServiceProvider(); + var store = sp.GetRequiredService>(); + await store.TryAddAsync(new TenantInfo { Id = "initech", Identifier = "initech" }); + + var context = new Mock(); + context.Setup(c => c.RequestServices).Returns(sp); + context.Setup(c => c.Features).Returns(new FeatureCollection()); + var response = new Mock(); + context.Setup(c => c.Response).Returns(response.Object); + + var itemsDict = new Dictionary(); + context.Setup(c => c.Items).Returns(itemsDict); + + var options = new ShortCircuitWhenOptions + { + Predicate = context => !context.IsResolved, + RedirectTo = new Uri("/tenant/notfound", UriKind.Relative) + }; + var optionsMock = new Mock>(); + optionsMock.Setup(c => c.Value).Returns(options); + + var calledNext = false; + var mw = new MultiTenantMiddleware(_ => + { + calledNext = true; + + return Task.CompletedTask; + }, optionsMock.Object); + + await mw.Invoke(context.Object); + + var mtc = (IMultiTenantContext?)context.Object.Items[typeof(IMultiTenantContext)]; + + Assert.NotNull(mtc); + Assert.False(mtc.IsResolved); + Assert.Null(mtc.TenantInfo); + Assert.False(calledNext); + response.Verify(r => r.Redirect("/tenant/notfound"), Times.Once); + } } \ No newline at end of file diff --git a/test/Finbuckle.MultiTenant.AspNetCore.Test/Routing/ExcludeFromMultiTenantResolutionShould.cs b/test/Finbuckle.MultiTenant.AspNetCore.Test/Routing/ExcludeFromMultiTenantResolutionShould.cs new file mode 100644 index 00000000..b8230c33 --- /dev/null +++ b/test/Finbuckle.MultiTenant.AspNetCore.Test/Routing/ExcludeFromMultiTenantResolutionShould.cs @@ -0,0 +1,91 @@ +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.AspNetCore.Extensions; +using Finbuckle.MultiTenant.AspNetCore.Strategies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Finbuckle.MultiTenant.AspNetCore.Test.Routing; + +public class ExcludeFromMultiTenantResolutionShould +{ + private const string EndpointStringResponse = "No tenant available."; + + [Theory] + [InlineData("/initech", "initech")] + [InlineData("/", "initech")] + public async Task ReturnExpectedResponse(string path, string identifier) + { + IWebHostBuilder hostBuilder = GetTestHostBuilder(identifier, "__tenant__", path); + using var server = new TestServer(hostBuilder); + var client = server.CreateClient(); + + var response = await client.GetStringAsync(path); + Assert.Equal(EndpointStringResponse, response); + + response = await client.GetStringAsync(path.TrimEnd('/') + "/tenantInfo"); + Assert.Equal("initech", response); + } + + private static IWebHostBuilder GetTestHostBuilder(string identifier, string sessionKey, string routePattern) + { + return new WebHostBuilder() + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(options => + { + options.IdleTimeout = TimeSpan.FromSeconds(5); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + }); + + services.AddMultiTenant() + .WithStrategy(ServiceLifetime.Singleton, sessionKey) + .WithInMemoryStore(); + + services.AddMvc(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseSession(); + app.Use(async (context, next) => + { + context.Session.SetString(sessionKey, identifier); + await next(context); + }); + app.UseMultiTenant(); + + app.UseEndpoints(endpoints => + { + var group = endpoints.MapGroup(routePattern); + + group.Map("/", async context => await WriteResponseAsync(context)) + .ExcludeFromMultiTenantResolution(); + + group.Map("/tenantInfo", async context => await WriteResponseAsync(context)); + }); + + var store = app.ApplicationServices.GetRequiredService>(); + store.TryAddAsync(new TenantInfo { Id = identifier, Identifier = identifier }).Wait(); + }); + } + + private static async Task WriteResponseAsync(HttpContext context) + { + var multiTenantContext = context.GetMultiTenantContext(); + + if (multiTenantContext.TenantInfo?.Id is null) + { + await context.Response.WriteAsync(EndpointStringResponse); + } + else + { + await context.Response.WriteAsync(multiTenantContext.TenantInfo.Id); + } + } +} \ No newline at end of file