diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/ApiExplorer/TenantRouteApiDescriptionProvider.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/ApiExplorer/TenantRouteApiDescriptionProvider.cs new file mode 100644 index 0000000..d9c835e --- /dev/null +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/ApiExplorer/TenantRouteApiDescriptionProvider.cs @@ -0,0 +1,140 @@ +using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options; +using CleanArchitecture.Extensions.Multitenancy.Configuration; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; + +namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer; + +/// +/// Ensures tenant route parameters appear in API descriptions when they are not bound by handlers. +/// +public sealed class TenantRouteApiDescriptionProvider : IApiDescriptionProvider +{ + private readonly MultitenancyOptions _options; + private readonly AspNetCoreMultitenancyOptions _aspNetCoreOptions; + private readonly IModelMetadataProvider? _modelMetadataProvider; + + /// + /// Initializes a new instance of the class. + /// + /// Multitenancy options. + /// ASP.NET Core multitenancy options. + /// Model metadata provider. + public TenantRouteApiDescriptionProvider( + IOptions options, + IOptions aspNetCoreOptions, + IModelMetadataProvider? modelMetadataProvider = null) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _aspNetCoreOptions = aspNetCoreOptions?.Value ?? throw new ArgumentNullException(nameof(aspNetCoreOptions)); + _modelMetadataProvider = modelMetadataProvider; + } + + /// + public int Order => 1000; + + /// + public void OnProvidersExecuting(ApiDescriptionProviderContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!_aspNetCoreOptions.EnableOpenApiIntegration) + { + return; + } + + foreach (var description in context.Results) + { + var template = description.ActionDescriptor?.AttributeRouteInfo?.Template; + if (!ShouldApplyTemplate(template)) + { + continue; + } + + var normalizedTemplate = NormalizeTemplate(template!); + if (!ContainsRouteParameter(normalizedTemplate, _options.RouteParameterName)) + { + continue; + } + + var relativePath = description.RelativePath; + if (!string.IsNullOrWhiteSpace(relativePath) + && !ContainsRouteParameter(relativePath, _options.RouteParameterName)) + { + description.RelativePath = normalizedTemplate; + } + + EnsurePathParameter(description, _options.RouteParameterName); + } + } + + /// + public void OnProvidersExecuted(ApiDescriptionProviderContext context) + { + } + + private static bool ShouldApplyTemplate(string? template) + { + if (string.IsNullOrWhiteSpace(template)) + { + return false; + } + + return template.IndexOf('[', StringComparison.OrdinalIgnoreCase) < 0; + } + + private static string NormalizeTemplate(string template) + { + return template.TrimStart('/'); + } + + private static bool ContainsRouteParameter(string template, string parameterName) + { + if (string.IsNullOrWhiteSpace(template) || string.IsNullOrWhiteSpace(parameterName)) + { + return false; + } + + var token = "{" + parameterName; + var index = template.IndexOf(token, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + return false; + } + + var nextIndex = index + token.Length; + if (nextIndex >= template.Length) + { + return false; + } + + var nextChar = template[nextIndex]; + return nextChar == '}' || nextChar == ':' || nextChar == '?'; + } + + private void EnsurePathParameter(ApiDescription description, string parameterName) + { + if (description.ParameterDescriptions.Any(parameter => + parameter.Source == BindingSource.Path + && string.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + var parameter = new ApiParameterDescription + { + Name = parameterName, + Source = BindingSource.Path, + Type = typeof(string), + IsRequired = true + }; + + if (_modelMetadataProvider is not null) + { + parameter.ModelMetadata = _modelMetadataProvider.GetMetadataForType(typeof(string)); + } + + description.ParameterDescriptions.Add(parameter); + } +} diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs index 7c31fee..b36a49e 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs @@ -1,4 +1,5 @@ using CleanArchitecture.Extensions.Multitenancy; +using CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Context; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Filters; using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; @@ -7,6 +8,7 @@ using CleanArchitecture.Extensions.Multitenancy.Configuration; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -52,6 +54,7 @@ public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore( services.TryAddSingleton(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Transient()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); if (autoUseMiddleware) { diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs index f007720..b13785d 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Options/AspNetCoreMultitenancyOptions.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Options/AspNetCoreMultitenancyOptions.cs index d89ddd4..8ebc3ef 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Options/AspNetCoreMultitenancyOptions.cs +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Options/AspNetCoreMultitenancyOptions.cs @@ -27,6 +27,11 @@ public sealed class AspNetCoreMultitenancyOptions /// public bool UseTraceIdentifierAsCorrelationId { get; set; } = true; + /// + /// Gets or sets a value indicating whether OpenAPI/ApiExplorer integration is enabled. + /// + public bool EnableOpenApiIntegration { get; set; } = true; + /// /// Gets the default options instance. /// diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md index 6e3a7bb..1b391b7 100644 --- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md +++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md @@ -82,6 +82,7 @@ builder.Services.AddCleanArchitectureMultitenancyAspNetCore( { aspNetOptions.CorrelationIdHeaderName = "X-Correlation-ID"; aspNetOptions.StoreTenantInHttpContextItems = true; + aspNetOptions.EnableOpenApiIntegration = true; }); ``` @@ -119,3 +120,4 @@ if (TenantProblemDetailsMapper.TryCreate(exception, httpContext, out var details - `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. +- Set `AspNetCoreMultitenancyOptions.EnableOpenApiIntegration` to `false` to disable ApiExplorer/OpenAPI adjustments. diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs index 322553d..a9fac1e 100644 --- a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs +++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs @@ -1,6 +1,8 @@ using System.Linq; using CleanArchitecture.Extensions.Multitenancy.AspNetCore; +using CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests; @@ -34,4 +36,18 @@ public void AddCleanArchitectureMultitenancyAspNetCore_registers_startup_filter_ Assert.NotEmpty(filters); } + + [Fact] + public void AddCleanArchitectureMultitenancyAspNetCore_registers_api_description_provider_by_default() + { + var services = new ServiceCollection(); + + services.AddCleanArchitectureMultitenancyAspNetCore(); + + using var provider = services.BuildServiceProvider(); + + var providers = provider.GetServices().ToList(); + + Assert.Contains(providers, item => item is TenantRouteApiDescriptionProvider); + } } diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantRouteApiDescriptionProviderTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantRouteApiDescriptionProviderTests.cs new file mode 100644 index 0000000..8ebd967 --- /dev/null +++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantRouteApiDescriptionProviderTests.cs @@ -0,0 +1,76 @@ +using CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer; +using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options; +using CleanArchitecture.Extensions.Multitenancy.Configuration; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Routing; +using OptionsFactory = Microsoft.Extensions.Options.Options; + +namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests; + +public class TenantRouteApiDescriptionProviderTests +{ + [Fact] + public void OnProvidersExecuting_inserts_tenant_route_parameter_when_missing() + { + var options = OptionsFactory.Create(new MultitenancyOptions { RouteParameterName = "tenantId" }); + var aspNetCoreOptions = OptionsFactory.Create(new AspNetCoreMultitenancyOptions { EnableOpenApiIntegration = true }); + var provider = new TenantRouteApiDescriptionProvider(options, aspNetCoreOptions, new EmptyModelMetadataProvider()); + + var actionDescriptor = new ActionDescriptor + { + AttributeRouteInfo = new AttributeRouteInfo + { + Template = "api/tenants/{tenantId}/TodoItems" + } + }; + + var description = new ApiDescription + { + ActionDescriptor = actionDescriptor, + RelativePath = "api/tenants/TodoItems" + }; + + var context = new ApiDescriptionProviderContext(new[] { actionDescriptor }); + context.Results.Add(description); + + provider.OnProvidersExecuting(context); + + Assert.Equal("api/tenants/{tenantId}/TodoItems", description.RelativePath); + var parameter = Assert.Single(description.ParameterDescriptions, item => + string.Equals(item.Name, "tenantId", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(BindingSource.Path, parameter.Source); + Assert.True(parameter.IsRequired); + } + + [Fact] + public void OnProvidersExecuting_noops_when_openapi_integration_disabled() + { + var options = OptionsFactory.Create(new MultitenancyOptions { RouteParameterName = "tenantId" }); + var aspNetCoreOptions = OptionsFactory.Create(new AspNetCoreMultitenancyOptions { EnableOpenApiIntegration = false }); + var provider = new TenantRouteApiDescriptionProvider(options, aspNetCoreOptions, new EmptyModelMetadataProvider()); + + var actionDescriptor = new ActionDescriptor + { + AttributeRouteInfo = new AttributeRouteInfo + { + Template = "api/tenants/{tenantId}/TodoItems" + } + }; + + var description = new ApiDescription + { + ActionDescriptor = actionDescriptor, + RelativePath = "api/tenants/TodoItems" + }; + + var context = new ApiDescriptionProviderContext(new[] { actionDescriptor }); + context.Results.Add(description); + + provider.OnProvidersExecuting(context); + + Assert.Equal("api/tenants/TodoItems", description.RelativePath); + Assert.Empty(description.ParameterDescriptions); + } +}