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);
+ }
+}