diff --git a/docs/samples/multitenancy/header-and-route-resolution.md b/docs/samples/multitenancy/header-and-route-resolution.md
index a271956..6f328b0 100644
--- a/docs/samples/multitenancy/header-and-route-resolution.md
+++ b/docs/samples/multitenancy/header-and-route-resolution.md
@@ -32,18 +32,18 @@ Document a sample that shows deterministic tenant resolution from route first, h
dotnet new ca-sln -cf None -o CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution --database sqlite
```
- Verify the output folder exists and contains the new solution file plus `src/` and `tests/`.
-2. Add NuGet package references for the multitenancy extensions (use the latest published versions from NuGet).
- - Packages: [CleanArchitecture.Extensions.Multitenancy](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy) and [CleanArchitecture.Extensions.Multitenancy.AspNetCore](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy.AspNetCore).
+2. Reference the multitenancy projects directly from the repository while iterating locally.
+ - When you switch back to NuGet, replace these `` entries with `` entries pointing to the latest published versions.
- `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj`:
```xml
-
+
```
- `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj`:
```xml
-
+
```
3. Configure `MultitenancyOptions` for route-first ordering (`Route > Host > Header > Query > Claim`), set header name `X-Tenant-ID`, require tenants by default, allow explicitly anonymous endpoints, and disable fallback tenants.
diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md
index 9784896..8a1d92a 100644
--- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md
+++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md
@@ -7,24 +7,20 @@ The project was generated using the [Clean.Architecture.Solution.Template](https
### Step 1: Create the base solution
Generate the empty Web API-only solution with SQLite using the Clean Architecture template. This matches the baseline template so the multitenancy changes are easy to compare and repeat.
-### Step 2: Add multitenancy NuGet packages
-Reference the published Multitenancy packages from NuGet (use the latest versions available).
-
-Packages:
-- [CleanArchitecture.Extensions.Multitenancy](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy)
-- [CleanArchitecture.Extensions.Multitenancy.AspNetCore](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy.AspNetCore)
+### Step 2: Reference multitenancy projects locally
+Reference the multitenancy projects directly from the repository while iterating locally. When you move back to NuGet, replace these `` entries with `` entries that target the latest published versions.
`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj`:
```xml
-
+
```
`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj`:
```xml
-
+
```
diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj
index 0307675..d7d8de2 100644
--- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj
+++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj
index eb56967..2ab2e01 100644
--- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj
+++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json
index 7503331..6597901 100644
--- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json
+++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json
@@ -6,7 +6,7 @@
"version": "1.0.0"
},
"paths": {
- "/api/TodoItems": {
+ "/api/tenants/{tenantId}/TodoItems": {
"get": {
"tags": [
"TodoItems"
@@ -42,6 +42,16 @@
"format": "int32"
},
"x-position": 3
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 4
}
],
"responses": {
@@ -67,6 +77,18 @@
"TodoItems"
],
"operationId": "CreateTodoItem",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "command",
"content": {
@@ -99,7 +121,7 @@
]
}
},
- "/api/TodoItems/{id}": {
+ "/api/tenants/{tenantId}/TodoItems/{id}": {
"put": {
"tags": [
"TodoItems"
@@ -115,6 +137,16 @@
"format": "int32"
},
"x-position": 1
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 3
}
],
"requestBody": {
@@ -158,6 +190,16 @@
"format": "int32"
},
"x-position": 1
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
}
],
"responses": {
@@ -172,7 +214,7 @@
]
}
},
- "/api/TodoItems/UpdateDetail/{id}": {
+ "/api/tenants/{tenantId}/TodoItems/UpdateDetail/{id}": {
"put": {
"tags": [
"TodoItems"
@@ -188,6 +230,16 @@
"format": "int32"
},
"x-position": 1
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 3
}
],
"requestBody": {
@@ -217,12 +269,24 @@
]
}
},
- "/api/TodoLists": {
+ "/api/tenants/{tenantId}/TodoLists": {
"get": {
"tags": [
"TodoLists"
],
"operationId": "GetTodoLists",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 1
+ }
+ ],
"responses": {
"200": {
"description": "",
@@ -246,6 +310,18 @@
"TodoLists"
],
"operationId": "CreateTodoList",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "command",
"content": {
@@ -278,7 +354,7 @@
]
}
},
- "/api/TodoLists/{id}": {
+ "/api/tenants/{tenantId}/TodoLists/{id}": {
"put": {
"tags": [
"TodoLists"
@@ -294,6 +370,16 @@
"format": "int32"
},
"x-position": 1
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 3
}
],
"requestBody": {
@@ -337,6 +423,16 @@
"format": "int32"
},
"x-position": 1
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
}
],
"responses": {
@@ -351,12 +447,24 @@
]
}
},
- "/api/Users/register": {
+ "/api/tenants/{tenantId}/Users/register": {
"post": {
"tags": [
"Users"
],
- "operationId": "PostApiUsersRegister",
+ "operationId": "PostApiTenantsUsersRegister",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "registration",
"content": {
@@ -386,12 +494,12 @@
}
}
},
- "/api/Users/login": {
+ "/api/tenants/{tenantId}/Users/login": {
"post": {
"tags": [
"Users"
],
- "operationId": "PostApiUsersLogin",
+ "operationId": "PostApiTenantsUsersLogin",
"parameters": [
{
"name": "useCookies",
@@ -410,6 +518,16 @@
"nullable": true
},
"x-position": 3
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 4
}
],
"requestBody": {
@@ -438,12 +556,24 @@
}
}
},
- "/api/Users/refresh": {
+ "/api/tenants/{tenantId}/Users/refresh": {
"post": {
"tags": [
"Users"
],
- "operationId": "PostApiUsersRefresh",
+ "operationId": "PostApiTenantsUsersRefresh",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "refreshRequest",
"content": {
@@ -470,12 +600,12 @@
}
}
},
- "/api/Users/confirmEmail": {
+ "/api/tenants/{tenantId}/Users/confirmEmail": {
"get": {
"tags": [
"Users"
],
- "operationId": "GetApiUsersConfirmEmail",
+ "operationId": "GetApiTenantsUsersConfirmEmail",
"parameters": [
{
"name": "userId",
@@ -505,6 +635,16 @@
"nullable": true
},
"x-position": 3
+ },
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 4
}
],
"responses": {
@@ -514,12 +654,24 @@
}
}
},
- "/api/Users/resendConfirmationEmail": {
+ "/api/tenants/{tenantId}/Users/resendConfirmationEmail": {
"post": {
"tags": [
"Users"
],
- "operationId": "PostApiUsersResendConfirmationEmail",
+ "operationId": "PostApiTenantsUsersResendConfirmationEmail",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "resendRequest",
"content": {
@@ -539,12 +691,24 @@
}
}
},
- "/api/Users/forgotPassword": {
+ "/api/tenants/{tenantId}/Users/forgotPassword": {
"post": {
"tags": [
"Users"
],
- "operationId": "PostApiUsersForgotPassword",
+ "operationId": "PostApiTenantsUsersForgotPassword",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "resetRequest",
"content": {
@@ -574,12 +738,24 @@
}
}
},
- "/api/Users/resetPassword": {
+ "/api/tenants/{tenantId}/Users/resetPassword": {
"post": {
"tags": [
"Users"
],
- "operationId": "PostApiUsersResetPassword",
+ "operationId": "PostApiTenantsUsersResetPassword",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "resetRequest",
"content": {
@@ -609,12 +785,24 @@
}
}
},
- "/api/Users/manage/2fa": {
+ "/api/tenants/{tenantId}/Users/manage/2fa": {
"post": {
"tags": [
"Users"
],
- "operationId": "PostApiUsersManage2fa",
+ "operationId": "PostApiTenantsUsersManage2fa",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "tfaRequest",
"content": {
@@ -659,12 +847,24 @@
]
}
},
- "/api/Users/manage/info": {
+ "/api/tenants/{tenantId}/Users/manage/info": {
"get": {
"tags": [
"Users"
],
- "operationId": "GetApiUsersManageInfo",
+ "operationId": "GetApiTenantsUsersManageInfo",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 1
+ }
+ ],
"responses": {
"200": {
"description": "",
@@ -700,7 +900,19 @@
"tags": [
"Users"
],
- "operationId": "PostApiUsersManageInfo",
+ "operationId": "PostApiTenantsUsersManageInfo",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 2
+ }
+ ],
"requestBody": {
"x-name": "infoRequest",
"content": {
@@ -745,12 +957,24 @@
]
}
},
- "/api/WeatherForecasts": {
+ "/api/tenants/{tenantId}/WeatherForecasts": {
"get": {
"tags": [
"WeatherForecasts"
],
"operationId": "GetWeatherForecasts",
+ "parameters": [
+ {
+ "name": "tenantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ },
+ "x-position": 1
+ }
+ ],
"responses": {
"200": {
"description": "",
diff --git a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/ApiExplorer/TenantRouteApiDescriptionProvider.cs b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/ApiExplorer/TenantRouteApiDescriptionProvider.cs
index a70534c..d370ddc 100644
--- a/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/ApiExplorer/TenantRouteApiDescriptionProvider.cs
+++ b/src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/ApiExplorer/TenantRouteApiDescriptionProvider.cs
@@ -1,4 +1,5 @@
using System.Reflection;
+using System.Text.RegularExpressions;
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options;
using CleanArchitecture.Extensions.Multitenancy.Configuration;
using Microsoft.AspNetCore.Mvc.Abstractions;
@@ -13,6 +14,10 @@ namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer;
///
public sealed class TenantRouteApiDescriptionProvider : IApiDescriptionProvider
{
+ private static readonly Regex DisplayNameRouteRegex = new(
+ @"(?:^|HTTP:\s*)(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(?/\S+)",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
private readonly MultitenancyOptions _options;
private readonly AspNetCoreMultitenancyOptions _aspNetCoreOptions;
private readonly IModelMetadataProvider? _modelMetadataProvider;
@@ -104,24 +109,78 @@ private static string NormalizeTemplate(string template)
return template;
}
- return TryGetRoutePatternTemplate(actionDescriptor);
+ return TryGetRoutePatternTemplate(actionDescriptor)
+ ?? TryGetTemplateFromEndpointMetadata(actionDescriptor)
+ ?? TryGetTemplateFromDisplayName(actionDescriptor);
}
private static string? TryGetRoutePatternTemplate(ActionDescriptor actionDescriptor)
{
- var routePatternProperty = actionDescriptor.GetType().GetProperty("RoutePattern", BindingFlags.Instance | BindingFlags.Public);
+ var routePatternProperty = actionDescriptor.GetType().GetProperty(
+ "RoutePattern",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (routePatternProperty is null)
{
return null;
}
var routePattern = routePatternProperty.GetValue(actionDescriptor);
+ return GetRawText(routePattern);
+ }
+
+ private static string? TryGetTemplateFromEndpointMetadata(ActionDescriptor actionDescriptor)
+ {
+ if (actionDescriptor.EndpointMetadata is null || actionDescriptor.EndpointMetadata.Count == 0)
+ {
+ return null;
+ }
+
+ foreach (var metadata in actionDescriptor.EndpointMetadata)
+ {
+ if (metadata is null)
+ {
+ continue;
+ }
+
+ var routePatternProperty = metadata.GetType().GetProperty(
+ "RoutePattern",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (routePatternProperty is not null)
+ {
+ var routePattern = routePatternProperty.GetValue(metadata);
+ var template = GetRawText(routePattern);
+ if (!string.IsNullOrWhiteSpace(template))
+ {
+ return template;
+ }
+ }
+
+ var templateProperty = metadata.GetType().GetProperty(
+ "Template",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (templateProperty?.PropertyType == typeof(string))
+ {
+ var template = templateProperty.GetValue(metadata) as string;
+ if (!string.IsNullOrWhiteSpace(template))
+ {
+ return template;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static string? GetRawText(object? routePattern)
+ {
if (routePattern is null)
{
return null;
}
- var rawTextProperty = routePattern.GetType().GetProperty("RawText", BindingFlags.Instance | BindingFlags.Public);
+ var rawTextProperty = routePattern.GetType().GetProperty(
+ "RawText",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (rawTextProperty?.PropertyType == typeof(string))
{
return rawTextProperty.GetValue(routePattern) as string;
@@ -130,6 +189,23 @@ private static string NormalizeTemplate(string template)
return routePattern.ToString();
}
+ private static string? TryGetTemplateFromDisplayName(ActionDescriptor actionDescriptor)
+ {
+ var displayName = actionDescriptor.DisplayName;
+ if (string.IsNullOrWhiteSpace(displayName))
+ {
+ return null;
+ }
+
+ var match = DisplayNameRouteRegex.Match(displayName);
+ if (!match.Success)
+ {
+ return null;
+ }
+
+ return match.Groups["path"].Value;
+ }
+
private static bool ContainsRouteParameter(string template, string parameterName)
{
if (string.IsNullOrWhiteSpace(template) || string.IsNullOrWhiteSpace(parameterName))
diff --git a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantRouteApiDescriptionProviderTests.cs b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantRouteApiDescriptionProviderTests.cs
index 74fff47..0e937df 100644
--- a/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantRouteApiDescriptionProviderTests.cs
+++ b/tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/TenantRouteApiDescriptionProviderTests.cs
@@ -117,4 +117,75 @@ private sealed class StubRouteActionDescriptor : ActionDescriptor
{
public StubRoutePattern? RoutePattern { get; set; }
}
+
+ [Fact]
+ public void OnProvidersExecuting_uses_endpoint_metadata_route_pattern_when_available()
+ {
+ 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
+ {
+ EndpointMetadata = new List