Skip to content

Commit 154d3f8

Browse files
authored
Feature/multitenancy openapi (#46)
* feat: add openapi integration toggle add EnableOpenApiIntegration to AspNetCore options and gate ApiExplorer updates when disabled add coverage for provider behavior and document the opt-out in the README * fix: resolve exception handler warning add missing Diagnostics import so IExceptionHandler cref resolves in XML docs
1 parent b5214c3 commit 154d3f8

File tree

7 files changed

+243
-0
lines changed

7 files changed

+243
-0
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options;
2+
using CleanArchitecture.Extensions.Multitenancy.Configuration;
3+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
4+
using Microsoft.AspNetCore.Mvc.ModelBinding;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer;
8+
9+
/// <summary>
10+
/// Ensures tenant route parameters appear in API descriptions when they are not bound by handlers.
11+
/// </summary>
12+
public sealed class TenantRouteApiDescriptionProvider : IApiDescriptionProvider
13+
{
14+
private readonly MultitenancyOptions _options;
15+
private readonly AspNetCoreMultitenancyOptions _aspNetCoreOptions;
16+
private readonly IModelMetadataProvider? _modelMetadataProvider;
17+
18+
/// <summary>
19+
/// Initializes a new instance of the <see cref="TenantRouteApiDescriptionProvider"/> class.
20+
/// </summary>
21+
/// <param name="options">Multitenancy options.</param>
22+
/// <param name="aspNetCoreOptions">ASP.NET Core multitenancy options.</param>
23+
/// <param name="modelMetadataProvider">Model metadata provider.</param>
24+
public TenantRouteApiDescriptionProvider(
25+
IOptions<MultitenancyOptions> options,
26+
IOptions<AspNetCoreMultitenancyOptions> aspNetCoreOptions,
27+
IModelMetadataProvider? modelMetadataProvider = null)
28+
{
29+
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
30+
_aspNetCoreOptions = aspNetCoreOptions?.Value ?? throw new ArgumentNullException(nameof(aspNetCoreOptions));
31+
_modelMetadataProvider = modelMetadataProvider;
32+
}
33+
34+
/// <inheritdoc />
35+
public int Order => 1000;
36+
37+
/// <inheritdoc />
38+
public void OnProvidersExecuting(ApiDescriptionProviderContext context)
39+
{
40+
ArgumentNullException.ThrowIfNull(context);
41+
42+
if (!_aspNetCoreOptions.EnableOpenApiIntegration)
43+
{
44+
return;
45+
}
46+
47+
foreach (var description in context.Results)
48+
{
49+
var template = description.ActionDescriptor?.AttributeRouteInfo?.Template;
50+
if (!ShouldApplyTemplate(template))
51+
{
52+
continue;
53+
}
54+
55+
var normalizedTemplate = NormalizeTemplate(template!);
56+
if (!ContainsRouteParameter(normalizedTemplate, _options.RouteParameterName))
57+
{
58+
continue;
59+
}
60+
61+
var relativePath = description.RelativePath;
62+
if (!string.IsNullOrWhiteSpace(relativePath)
63+
&& !ContainsRouteParameter(relativePath, _options.RouteParameterName))
64+
{
65+
description.RelativePath = normalizedTemplate;
66+
}
67+
68+
EnsurePathParameter(description, _options.RouteParameterName);
69+
}
70+
}
71+
72+
/// <inheritdoc />
73+
public void OnProvidersExecuted(ApiDescriptionProviderContext context)
74+
{
75+
}
76+
77+
private static bool ShouldApplyTemplate(string? template)
78+
{
79+
if (string.IsNullOrWhiteSpace(template))
80+
{
81+
return false;
82+
}
83+
84+
return template.IndexOf('[', StringComparison.OrdinalIgnoreCase) < 0;
85+
}
86+
87+
private static string NormalizeTemplate(string template)
88+
{
89+
return template.TrimStart('/');
90+
}
91+
92+
private static bool ContainsRouteParameter(string template, string parameterName)
93+
{
94+
if (string.IsNullOrWhiteSpace(template) || string.IsNullOrWhiteSpace(parameterName))
95+
{
96+
return false;
97+
}
98+
99+
var token = "{" + parameterName;
100+
var index = template.IndexOf(token, StringComparison.OrdinalIgnoreCase);
101+
if (index < 0)
102+
{
103+
return false;
104+
}
105+
106+
var nextIndex = index + token.Length;
107+
if (nextIndex >= template.Length)
108+
{
109+
return false;
110+
}
111+
112+
var nextChar = template[nextIndex];
113+
return nextChar == '}' || nextChar == ':' || nextChar == '?';
114+
}
115+
116+
private void EnsurePathParameter(ApiDescription description, string parameterName)
117+
{
118+
if (description.ParameterDescriptions.Any(parameter =>
119+
parameter.Source == BindingSource.Path
120+
&& string.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase)))
121+
{
122+
return;
123+
}
124+
125+
var parameter = new ApiParameterDescription
126+
{
127+
Name = parameterName,
128+
Source = BindingSource.Path,
129+
Type = typeof(string),
130+
IsRequired = true
131+
};
132+
133+
if (_modelMetadataProvider is not null)
134+
{
135+
parameter.ModelMetadata = _modelMetadataProvider.GetMetadataForType(typeof(string));
136+
}
137+
138+
description.ParameterDescriptions.Add(parameter);
139+
}
140+
}

src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/DependencyInjectionExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using CleanArchitecture.Extensions.Multitenancy;
2+
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer;
23
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Context;
34
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Filters;
45
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware;
@@ -7,6 +8,7 @@
78
using CleanArchitecture.Extensions.Multitenancy.Configuration;
89
using Microsoft.AspNetCore.Diagnostics;
910
using Microsoft.AspNetCore.Hosting;
11+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
1012
using Microsoft.AspNetCore.Mvc;
1113
using Microsoft.Extensions.DependencyInjection;
1214
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -52,6 +54,7 @@ public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore(
5254
services.TryAddSingleton<ITenantResolutionContextFactory, DefaultTenantResolutionContextFactory>();
5355
services.TryAddScoped<TenantEnforcementEndpointFilter>();
5456
services.TryAddScoped<TenantEnforcementActionFilter>();
57+
services.TryAddEnumerable(ServiceDescriptor.Transient<IApiDescriptionProvider, TenantRouteApiDescriptionProvider>());
5558
services.TryAddEnumerable(ServiceDescriptor.Singleton<IExceptionHandler, TenantExceptionHandler>());
5659
if (autoUseMiddleware)
5760
{

src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Middleware/ExceptionHandlerStartupFilter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Diagnostics;
23
using Microsoft.AspNetCore.Hosting;
34

45
namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware;

src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/Options/AspNetCoreMultitenancyOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ public sealed class AspNetCoreMultitenancyOptions
2727
/// </summary>
2828
public bool UseTraceIdentifierAsCorrelationId { get; set; } = true;
2929

30+
/// <summary>
31+
/// Gets or sets a value indicating whether OpenAPI/ApiExplorer integration is enabled.
32+
/// </summary>
33+
public bool EnableOpenApiIntegration { get; set; } = true;
34+
3035
/// <summary>
3136
/// Gets the default options instance.
3237
/// </summary>

src/CleanArchitecture.Extensions.Multitenancy.AspNetCore/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ builder.Services.AddCleanArchitectureMultitenancyAspNetCore(
8282
{
8383
aspNetOptions.CorrelationIdHeaderName = "X-Correlation-ID";
8484
aspNetOptions.StoreTenantInHttpContextItems = true;
85+
aspNetOptions.EnableOpenApiIntegration = true;
8586
});
8687
```
8788

@@ -119,3 +120,4 @@ if (TenantProblemDetailsMapper.TryCreate(exception, httpContext, out var details
119120
- `GetTenantContext()` respects the configured `AspNetCoreMultitenancyOptions.HttpContextItemKey` when available.
120121
- `AddCleanArchitectureMultitenancyAspNetCore` registers `TenantExceptionHandler` so `UseExceptionHandler` can map multitenancy exceptions to ProblemDetails.
121122
- Keep `autoUseExceptionHandler` enabled (default) when the host pipeline does not call `UseExceptionHandler()` (or overrides it), so your registered `IExceptionHandler` implementations still run.
123+
- Set `AspNetCoreMultitenancyOptions.EnableOpenApiIntegration` to `false` to disable ApiExplorer/OpenAPI adjustments.

tests/CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests/DependencyInjectionExtensionsTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System.Linq;
22
using CleanArchitecture.Extensions.Multitenancy.AspNetCore;
3+
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer;
34
using Microsoft.AspNetCore.Hosting;
5+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
46
using Microsoft.Extensions.DependencyInjection;
57

68
namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests;
@@ -34,4 +36,18 @@ public void AddCleanArchitectureMultitenancyAspNetCore_registers_startup_filter_
3436

3537
Assert.NotEmpty(filters);
3638
}
39+
40+
[Fact]
41+
public void AddCleanArchitectureMultitenancyAspNetCore_registers_api_description_provider_by_default()
42+
{
43+
var services = new ServiceCollection();
44+
45+
services.AddCleanArchitectureMultitenancyAspNetCore();
46+
47+
using var provider = services.BuildServiceProvider();
48+
49+
var providers = provider.GetServices<IApiDescriptionProvider>().ToList();
50+
51+
Assert.Contains(providers, item => item is TenantRouteApiDescriptionProvider);
52+
}
3753
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.ApiExplorer;
2+
using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Options;
3+
using CleanArchitecture.Extensions.Multitenancy.Configuration;
4+
using Microsoft.AspNetCore.Mvc.Abstractions;
5+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
6+
using Microsoft.AspNetCore.Mvc.ModelBinding;
7+
using Microsoft.AspNetCore.Mvc.Routing;
8+
using OptionsFactory = Microsoft.Extensions.Options.Options;
9+
10+
namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Tests;
11+
12+
public class TenantRouteApiDescriptionProviderTests
13+
{
14+
[Fact]
15+
public void OnProvidersExecuting_inserts_tenant_route_parameter_when_missing()
16+
{
17+
var options = OptionsFactory.Create(new MultitenancyOptions { RouteParameterName = "tenantId" });
18+
var aspNetCoreOptions = OptionsFactory.Create(new AspNetCoreMultitenancyOptions { EnableOpenApiIntegration = true });
19+
var provider = new TenantRouteApiDescriptionProvider(options, aspNetCoreOptions, new EmptyModelMetadataProvider());
20+
21+
var actionDescriptor = new ActionDescriptor
22+
{
23+
AttributeRouteInfo = new AttributeRouteInfo
24+
{
25+
Template = "api/tenants/{tenantId}/TodoItems"
26+
}
27+
};
28+
29+
var description = new ApiDescription
30+
{
31+
ActionDescriptor = actionDescriptor,
32+
RelativePath = "api/tenants/TodoItems"
33+
};
34+
35+
var context = new ApiDescriptionProviderContext(new[] { actionDescriptor });
36+
context.Results.Add(description);
37+
38+
provider.OnProvidersExecuting(context);
39+
40+
Assert.Equal("api/tenants/{tenantId}/TodoItems", description.RelativePath);
41+
var parameter = Assert.Single(description.ParameterDescriptions, item =>
42+
string.Equals(item.Name, "tenantId", StringComparison.OrdinalIgnoreCase));
43+
Assert.Equal(BindingSource.Path, parameter.Source);
44+
Assert.True(parameter.IsRequired);
45+
}
46+
47+
[Fact]
48+
public void OnProvidersExecuting_noops_when_openapi_integration_disabled()
49+
{
50+
var options = OptionsFactory.Create(new MultitenancyOptions { RouteParameterName = "tenantId" });
51+
var aspNetCoreOptions = OptionsFactory.Create(new AspNetCoreMultitenancyOptions { EnableOpenApiIntegration = false });
52+
var provider = new TenantRouteApiDescriptionProvider(options, aspNetCoreOptions, new EmptyModelMetadataProvider());
53+
54+
var actionDescriptor = new ActionDescriptor
55+
{
56+
AttributeRouteInfo = new AttributeRouteInfo
57+
{
58+
Template = "api/tenants/{tenantId}/TodoItems"
59+
}
60+
};
61+
62+
var description = new ApiDescription
63+
{
64+
ActionDescriptor = actionDescriptor,
65+
RelativePath = "api/tenants/TodoItems"
66+
};
67+
68+
var context = new ApiDescriptionProviderContext(new[] { actionDescriptor });
69+
context.Results.Add(description);
70+
71+
provider.OnProvidersExecuting(context);
72+
73+
Assert.Equal("api/tenants/TodoItems", description.RelativePath);
74+
Assert.Empty(description.ParameterDescriptions);
75+
}
76+
}

0 commit comments

Comments
 (0)