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 1d9a6c6c..886e0840 100644 --- a/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs +++ b/src/Finbuckle.MultiTenant.AspNetCore/Extensions/MultiTenantBuilderExtensions.cs @@ -25,6 +25,39 @@ 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 options) + where TTenantInfo : class, ITenantInfo, new() + { + builder.Services.Configure(options); + + 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() + { + builder.Services.Configure(config => config.Predicate = context => !context.IsResolved); + + return builder; + } + /// /// 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 23ffcf22..17dfcec0 100644 --- a/src/Finbuckle.MultiTenant.AspNetCore/Internal/MultiTenantMiddleware.cs +++ b/src/Finbuckle.MultiTenant.AspNetCore/Internal/MultiTenantMiddleware.cs @@ -3,8 +3,11 @@ using System.Threading.Tasks; 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; @@ -14,14 +17,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(); @@ -30,7 +46,8 @@ public async Task Invoke(HttpContext context) var multiTenantContext = await resolver.ResolveAsync(context); mtcSetter.MultiTenantContext = multiTenantContext; context.Items[typeof(IMultiTenantContext)] = multiTenantContext; - - await next(context); + + if (options?.Predicate is null || !options.Predicate(multiTenantContext)) + await next(context); } } \ 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..bf3050fd --- /dev/null +++ b/src/Finbuckle.MultiTenant.AspNetCore/Options/ShortCircuitWhenOptions.cs @@ -0,0 +1,25 @@ +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; + } + } +} \ 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 86d61ac0..847de112 100644 --- a/test/Finbuckle.MultiTenant.AspNetCore.Test/MultiTenantMiddlewareShould.cs +++ b/test/Finbuckle.MultiTenant.AspNetCore.Test/MultiTenantMiddlewareShould.cs @@ -5,8 +5,11 @@ using System.Threading.Tasks; 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; @@ -27,6 +30,7 @@ public async void 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); @@ -41,6 +45,45 @@ public async void SetHttpContextItemIfTenantFound() Assert.Equal("initech", mtc.TenantInfo.Id); } + [Fact] + public async void 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 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); + } + [Fact] public async void SetTenantAccessor() { @@ -54,7 +97,8 @@ public async void 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); @@ -89,7 +133,8 @@ public async void 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); @@ -110,4 +155,44 @@ public async void NotSetTenantAccessorIfNoTenant() Assert.False(mtc.IsResolved); Assert.Null(mtc.TenantInfo); } + + [Fact] + public async void 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 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); + } } \ 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