Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Ensures tenant route parameters appear in API descriptions when they are not bound by handlers.
/// </summary>
public sealed class TenantRouteApiDescriptionProvider : IApiDescriptionProvider
{
private readonly MultitenancyOptions _options;
private readonly AspNetCoreMultitenancyOptions _aspNetCoreOptions;
private readonly IModelMetadataProvider? _modelMetadataProvider;

/// <summary>
/// Initializes a new instance of the <see cref="TenantRouteApiDescriptionProvider"/> class.
/// </summary>
/// <param name="options">Multitenancy options.</param>
/// <param name="aspNetCoreOptions">ASP.NET Core multitenancy options.</param>
/// <param name="modelMetadataProvider">Model metadata provider.</param>
public TenantRouteApiDescriptionProvider(
IOptions<MultitenancyOptions> options,
IOptions<AspNetCoreMultitenancyOptions> aspNetCoreOptions,
IModelMetadataProvider? modelMetadataProvider = null)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_aspNetCoreOptions = aspNetCoreOptions?.Value ?? throw new ArgumentNullException(nameof(aspNetCoreOptions));
_modelMetadataProvider = modelMetadataProvider;
}

/// <inheritdoc />
public int Order => 1000;

/// <inheritdoc />
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);
}
}

/// <inheritdoc />
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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -52,6 +54,7 @@ public static IServiceCollection AddCleanArchitectureMultitenancyAspNetCore(
services.TryAddSingleton<ITenantResolutionContextFactory, DefaultTenantResolutionContextFactory>();
services.TryAddScoped<TenantEnforcementEndpointFilter>();
services.TryAddScoped<TenantEnforcementActionFilter>();
services.TryAddEnumerable(ServiceDescriptor.Transient<IApiDescriptionProvider, TenantRouteApiDescriptionProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IExceptionHandler, TenantExceptionHandler>());
if (autoUseMiddleware)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;

namespace CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ public sealed class AspNetCoreMultitenancyOptions
/// </summary>
public bool UseTraceIdentifierAsCorrelationId { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether OpenAPI/ApiExplorer integration is enabled.
/// </summary>
public bool EnableOpenApiIntegration { get; set; } = true;

/// <summary>
/// Gets the default options instance.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ builder.Services.AddCleanArchitectureMultitenancyAspNetCore(
{
aspNetOptions.CorrelationIdHeaderName = "X-Correlation-ID";
aspNetOptions.StoreTenantInHttpContextItems = true;
aspNetOptions.EnableOpenApiIntegration = true;
});
```

Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<IApiDescriptionProvider>().ToList();

Assert.Contains(providers, item => item is TenantRouteApiDescriptionProvider);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading