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 + { + new StubEndpointMetadata(new StubRoutePattern("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); + } + + private sealed class StubEndpointMetadata + { + public StubEndpointMetadata(StubRoutePattern routePattern) + { + RoutePattern = routePattern; + } + + public StubRoutePattern RoutePattern { get; } + } + + [Fact] + public void OnProvidersExecuting_uses_display_name_route_when_other_sources_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 + { + DisplayName = "HTTP: GET /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); + } }