diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs index 7bbc4fe..7c31fee 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs @@ -28,11 +28,16 @@ public static class DependencyInjectionExtensions /// Whether to add the multitenancy middleware automatically. Use only for host/header resolution. /// Route/claim resolution requires manual ordering after routing/authentication. /// + /// + /// Whether to add the default exception handler middleware so registered implementations run. + /// Helpful when the host overrides exception handling (for example, the template's empty UseExceptionHandler(options => {{ }})). + /// public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore( this IServiceCollection services, Action? configureCore = null, Action? configureAspNetCore = null, - bool autoUseMiddleware = false) + bool autoUseMiddleware = false, + bool autoUseExceptionHandler = true) { ArgumentNullException.ThrowIfNull(services); @@ -52,6 +57,10 @@ public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore( { services.TryAddEnumerable(ServiceDescriptor.Singleton()); } + if (autoUseExceptionHandler) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } return services; } diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs new file mode 100644 index 0000000..f007720 --- /dev/null +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; + +/// +/// Ensures the default exception handler middleware is registered so implementations run. +/// +internal sealed class ExceptionHandlerStartupFilter : IStartupFilter +{ + public Action Configure(Action next) + { + ArgumentNullException.ThrowIfNull(next); + + return app => + { + app.UseExceptionHandler(); + next(app); + }; + } +} diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md index 9535c4e..6e3a7bb 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md @@ -10,7 +10,7 @@ ASP.NET Core adapter for CleanArchitecture.Extensions.Multitenancy. It provides - Endpoint metadata helpers (`RequireTenant`, `AllowAnonymousTenant`, `WithTenantHeader`, `WithTenantRoute`). - ProblemDetails mapping for multitenancy exceptions. - Exception handler integration (`TenantExceptionHandler`) for consistent ProblemDetails responses. -- Optional startup filter to auto-add the middleware when you cannot modify `Program.cs`. +- Optional startup filters to auto-add the middleware and the default exception handler when you cannot modify `Program.cs`. ## Install @@ -25,10 +25,12 @@ dotnet add package CleanArchitecture.Extensions.Multitenancy.AspNetCore ```csharp using CleanArchitecture.Extensions.Multitenancy.AspNetCore; -builder.Services.AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: true); +builder.Services.AddCleanArchitectureMultitenancyAspNetCore( + autoUseMiddleware: true, + autoUseExceptionHandler: true); // default for the template ``` -Use `autoUseMiddleware` when header or host resolution is enough and you do not depend on route values. For claim- or route-based resolution, disable it and place `app.UseCleanArchitectureMultitenancy()` after authentication or routing. If you want tenant resolution exceptions to flow through your global exception handler, place it after `app.UseExceptionHandler(...)`. +Use `autoUseMiddleware` when header or host resolution is enough and you do not depend on route values. For claim- or route-based resolution, disable it and place `app.UseCleanArchitectureMultitenancy()` after authentication or routing. `autoUseExceptionHandler` is enabled by default to work around the template's `UseExceptionHandler(options => { })` stub; disable it if you already call `app.UseExceptionHandler()` with the new IExceptionHandler pipeline intact. ### 2) Add the middleware (manual) @@ -69,6 +71,7 @@ using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options; using CleanArchitecture.Extensions.Multitenancy.Configuration; builder.Services.AddCleanArchitectureMultitenancyAspNetCore( + autoUseExceptionHandler: true, coreOptions => { coreOptions.HeaderNames = new[] { "X-Tenant-ID" }; @@ -87,7 +90,7 @@ builder.Services.AddCleanArchitectureMultitenancyAspNetCore( - Prefer header or host resolution to keep the template unchanged. - Route-based tenancy requires routing middleware before multitenancy; opt in only if you can adjust the pipeline. -Example for route-based tenants: +Example for route-based tenants (note the default exception handler already runs): ```csharp app.UseRouting(); @@ -115,3 +118,4 @@ if (TenantProblemDetailsMapper.TryCreate(exception, httpContext, out var details - The middleware stores the resolved `TenantContext` in `HttpContext.Items` by default. - `GetTenantContext()` respects the configured `AspNetCoreMultitenancyOptions.HttpContextItemKey` when available. - `AddCleanArchitectureMultitenancyAspNetCore` registers `TenantExceptionHandler` so `UseExceptionHandler` can map multitenancy exceptions to ProblemDetails. +- Keep `autoUseExceptionHandler` enabled (default) when the host pipeline does not call `UseExceptionHandler()` (or overrides it), so your registered `IExceptionHandler` implementations still run. diff --git a/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs b/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs index 2128abd..544c549 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.EFCore/Options/EfCoreMultitenancyOptions.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using CleanArchitecture.Extensions.Multitenancy.Abstractions; namespace CleanArchitecture.Extensions.Multitenancy.EFCore.Options; @@ -140,12 +141,14 @@ public bool EnableSaveChangesEnforcement return DefaultSchema; } - if (string.IsNullOrWhiteSpace(SchemaNameFormat) || tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId)) + var normalizedId = NormalizeTenantId(tenant?.TenantId); + + if (string.IsNullOrWhiteSpace(SchemaNameFormat) || normalizedId is null) { return DefaultSchema; } - return string.Format(CultureInfo.InvariantCulture, SchemaNameFormat, tenant.TenantId); + return string.Format(CultureInfo.InvariantCulture, SchemaNameFormat, normalizedId); } /// @@ -159,11 +162,33 @@ public bool EnableSaveChangesEnforcement return ConnectionStringProvider(tenant); } - if (string.IsNullOrWhiteSpace(ConnectionStringFormat) || tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId)) + var normalizedId = NormalizeTenantId(tenant?.TenantId); + + if (string.IsNullOrWhiteSpace(ConnectionStringFormat) || normalizedId is null) { return null; } - return string.Format(CultureInfo.InvariantCulture, ConnectionStringFormat, tenant.TenantId); + return string.Format(CultureInfo.InvariantCulture, ConnectionStringFormat, normalizedId); + } + + private static string? NormalizeTenantId(string? tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return null; + } + + var builder = new StringBuilder(); + + foreach (var ch in tenantId) + { + if (char.IsLetterOrDigit(ch) || ch is '-' or '_') + { + builder.Append(char.ToLowerInvariant(ch)); + } + } + + return builder.Length == 0 ? null : builder.ToString(); } } diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs b/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs index 11b3c1d..760223c 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Configuration/MultitenancyOptions.cs @@ -14,7 +14,7 @@ public sealed class MultitenancyOptions /// Gets or sets a value indicating whether tenant-less requests are allowed when explicitly marked optional. /// When false, optional requirements are treated as required. /// - public bool AllowAnonymous { get; set; } + public bool AllowAnonymous { get; set; } = false; /// /// Gets or sets a fallback tenant used when no tenant is resolved. diff --git a/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs b/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs index 6750e53..8f6b2cf 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/Context/TenantResolver.cs @@ -120,7 +120,8 @@ public TenantResolver( return null; } - return await _tenantCache.GetAsync(tenantId, cancellationToken).ConfigureAwait(false); + var cached = await _tenantCache.GetAsync(tenantId, cancellationToken).ConfigureAwait(false); + return cached is null ? null : TenantInfo.From(cached); } private async Task ResolveFromStoreAsync(string tenantId, CancellationToken cancellationToken) @@ -137,11 +138,13 @@ public TenantResolver( return null; } + var snapshot = TenantInfo.From(tenant); + if (_tenantCache is not null) { - await _tenantCache.SetAsync(tenant, _options.ResolutionCacheTtl, cancellationToken).ConfigureAwait(false); + await _tenantCache.SetAsync(snapshot, _options.ResolutionCacheTtl, cancellationToken).ConfigureAwait(false); } - return tenant; + return snapshot; } } diff --git a/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs index 7c037dd..4e8157d 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy/DependencyInjectionExtensions.cs @@ -5,6 +5,7 @@ using CleanArchitecture.Extensions.Multitenancy.Providers; using CleanArchitecture.Extensions.Multitenancy.Serialization; using MediatR; +using MediatR.Pipeline; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -37,6 +38,7 @@ public static IServiceCollection AddCleanArchitectureMultitenancy( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(); services.TryAddSingleton(); + EnsureCorrelationPreProcessorPriority(services); services.TryAddScoped(); services.TryAddScoped(); @@ -77,8 +79,33 @@ public static MediatRServiceConfiguration AddCleanArchitectureMultitenancyCorrel { ArgumentNullException.ThrowIfNull(configuration); - configuration.AddOpenRequestPreProcessor(typeof(TenantCorrelationPreProcessor<>)); - configuration.AddOpenRequestPostProcessor(typeof(TenantCorrelationPostProcessor<,>)); + // Correlation pre/post processors are registered automatically; this method is kept for backward compatibility. return configuration; } + + private static void EnsureCorrelationPreProcessorPriority(IServiceCollection services) + { + var preProcessorDescriptor = ServiceDescriptor.Transient(typeof(IRequestPreProcessor<>), typeof(TenantCorrelationPreProcessor<>)); + var postProcessorDescriptor = ServiceDescriptor.Transient(typeof(IRequestPostProcessor<,>), typeof(TenantCorrelationPostProcessor<,>)); + + // Remove existing registrations to avoid duplicates and control ordering. + RemoveDescriptor(services, typeof(IRequestPreProcessor<>), typeof(TenantCorrelationPreProcessor<>)); + RemoveDescriptor(services, typeof(IRequestPostProcessor<,>), typeof(TenantCorrelationPostProcessor<,>)); + + // Insert the pre-processor at the front so it runs before template LoggingBehaviour. + services.Insert(0, preProcessorDescriptor); + services.Add(postProcessorDescriptor); + } + + private static void RemoveDescriptor(IServiceCollection services, Type serviceType, Type implementationType) + { + for (var i = services.Count - 1; i >= 0; i--) + { + var descriptor = services[i]; + if (descriptor.ServiceType == serviceType && descriptor.ImplementationType == implementationType) + { + services.RemoveAt(i); + } + } + } } diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs index efbc496..322553d 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using CleanArchitecture.Extensions.Multitenancy.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -6,6 +7,20 @@ namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests; public class DependencyInjectionExtensionsTests { + [Fact] + public void AddCleanArchitectureMultitenancyAspNetCore_registers_exception_handler_startup_filter_by_default() + { + var services = new ServiceCollection(); + + services.AddCleanArchitectureMultitenancyAspNetCore(); + + using var provider = services.BuildServiceProvider(); + + var filters = provider.GetServices().ToList(); + + Assert.Contains(filters, filter => filter.GetType().Name == "ExceptionHandlerStartupFilter"); + } + [Fact] public void AddCleanArchitectureMultitenancyAspNetCore_registers_startup_filter_when_enabled() {