Skip to content
Draft
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,52 @@
ο»Ώusing Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.HttpExtensions;

public static class EndpointDataSourceServiceCollectionExtensions
{
/// <summary>
/// Registers <paramref name="configure"/> so the offline OpenAPI generator (<c>dotnet swagger tofile</c>) can
/// discover Minimal API endpoints it would otherwise miss β€” it never runs the <c>Configure</c> pipeline where
/// the endpoints are mapped. Multiple features may call this; a single <see cref="StandaloneEndpointDataSource"/>
/// composes all of their mappings, because ApiExplorer injects only one <see cref="EndpointDataSource"/> and a
/// source-per-feature would let the last-registered hide the rest.
///
/// Intentionally a no-op outside of swagger generation: at runtime the endpoints are served by the host's
/// <c>UseEndpoints</c> mapping, and registering this source then would replace the default composite
/// <see cref="EndpointDataSource"/> that runtime routing and link generation depend on.
/// </summary>
public static IServiceCollection AddOpenApiEndpointDataSource(
this IServiceCollection services, Action<IEndpointRouteBuilder> configure)
{
// Set by dev/generate_openapi_files.ps1 (and the CI spec-generation job) while running the CLI generator.
var generatingOpenApi = string.Equals(
Environment.GetEnvironmentVariable("swaggerGen"), "true", StringComparison.OrdinalIgnoreCase);
if (!generatingOpenApi)
{
return services;
}

// Register the composing data source once, on the first feature. Its factory runs only when ApiExplorer
// resolves EndpointDataSource β€” by then every feature has registered its delegate, so the single instance
// sees them all. The plain AddSingleton appends, so it wins over any framework-default EndpointDataSource.
var firstFeature = services.All(d => d.ServiceType != typeof(OpenApiEndpointRouteConfiguration));
services.AddSingleton(new OpenApiEndpointRouteConfiguration(configure));
if (firstFeature)
{
services.AddSingleton<EndpointDataSource>(sp => new StandaloneEndpointDataSource(
sp, sp.GetServices<OpenApiEndpointRouteConfiguration>().Select(c => c.Configure)));
}

return services;
}

/// <summary>
/// Wraps a feature's endpoint-mapping delegate so the set of features can be enumerated from DI without
/// registering a bare <see cref="Action{T}"/>, which would risk colliding with unrelated delegate registrations.
/// </summary>
private sealed class OpenApiEndpointRouteConfiguration(Action<IEndpointRouteBuilder> configure)
{
public Action<IEndpointRouteBuilder> Configure { get; } = configure;
}
}
52 changes: 52 additions & 0 deletions src/HttpExtensions/StandaloneEndpointDataSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
ο»Ώusing Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Primitives;

namespace Bit.HttpExtensions;

/// <summary>
/// An <see cref="EndpointDataSource"/> that materializes Minimal API endpoints from one or more route-mapping
/// delegates outside the request pipeline.
///
/// Hosts that use the Startup/<c>Configure</c> pattern map their Minimal API endpoints with <c>UseEndpoints</c>.
/// The offline OpenAPI generator (<c>dotnet swagger tofile</c>) never executes <c>Configure</c>, so those
/// endpoints are invisible to ApiExplorer/Swashbuckle and the generated spec omits them. Registering this source
/// in DI makes the same endpoints discoverable without the request pipeline, while <c>UseEndpoints</c> still picks
/// them up at runtime.
///
/// A single instance composes every feature's mapping delegate: ApiExplorer injects one
/// <see cref="EndpointDataSource"/>, so a source-per-feature would let the last-registered hide the rest. See
/// <see cref="EndpointDataSourceServiceCollectionExtensions.AddOpenApiEndpointDataSource"/>.
/// </summary>
public sealed class StandaloneEndpointDataSource : EndpointDataSource
{
private readonly EndpointDataSource _source;

public StandaloneEndpointDataSource(
IServiceProvider serviceProvider, IEnumerable<Action<IEndpointRouteBuilder>> configure)
{
var routeBuilder = new StandaloneEndpointRouteBuilder(serviceProvider);
foreach (var map in configure)
{
map(routeBuilder);
}

_source = new CompositeEndpointDataSource(routeBuilder.DataSources);
}

public override IReadOnlyList<Endpoint> Endpoints => _source.Endpoints;

public override IChangeToken GetChangeToken() => _source.GetChangeToken();

/// <summary>
/// Minimal <see cref="IEndpointRouteBuilder"/> used only to materialize route groups into endpoints outside
/// of the request pipeline.
/// </summary>
private sealed class StandaloneEndpointRouteBuilder(IServiceProvider serviceProvider) : IEndpointRouteBuilder
{
public IServiceProvider ServiceProvider { get; } = serviceProvider;
public ICollection<EndpointDataSource> DataSources { get; } = new List<EndpointDataSource>();
public IApplicationBuilder CreateApplicationBuilder() => new ApplicationBuilder(ServiceProvider);
}
}
29 changes: 28 additions & 1 deletion src/SharedWeb/Swagger/ActionNameOperationFilter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώusing System.Text.Json;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Routing;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;

Expand All @@ -14,12 +16,37 @@ public class ActionNameOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (!context.ApiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action)) return;
var action = GetActionName(context.ApiDescription);
if (string.IsNullOrEmpty(action)) return;

operation.Extensions ??= new Dictionary<string, IOpenApiExtension>();
operation.Extensions.Add("x-action-name", new JsonNodeExtension(action));
// We can't do case changes in the codegen templates, so we also add the snake_case version of the action name
operation.Extensions.Add("x-action-name-snake-case", new JsonNodeExtension(JsonNamingPolicy.SnakeCaseLower.ConvertName(action)));
}

/// <summary>
/// Resolves the action name for an operation. MVC controllers expose it as the "action" route value.
/// Minimal API endpoints have none, so we derive it from the endpoint name set via <c>.WithName(...)</c>
/// (e.g. "Pam_AccessRequests_GetInbox" β†’ "GetInbox").
/// </summary>
private static string? GetActionName(ApiDescription apiDescription)
{
if (apiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action)
&& !string.IsNullOrEmpty(action))
{
return action;
}

var endpointName = apiDescription.ActionDescriptor.EndpointMetadata
.OfType<IEndpointNameMetadata>()
.LastOrDefault()?.EndpointName;
if (string.IsNullOrEmpty(endpointName))
{
return null;
}

var lastSeparator = endpointName.LastIndexOf('_');
return lastSeparator >= 0 ? endpointName[(lastSeparator + 1)..] : endpointName;
}
}
42 changes: 41 additions & 1 deletion src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώusing Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Swashbuckle.AspNetCore.SwaggerGen;
Expand All @@ -22,7 +24,7 @@ public static void InitializeSwaggerFilters(
// Set the operation ID to the name of the controller followed by the name of the function.
// Note that the "Controller" suffix for the controllers, and the "Async" suffix for the actions
// are removed already, so we don't need to do that ourselves.
config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}");
config.CustomOperationIds(BuildOperationId);
// Because we're setting custom operation IDs, we need to ensure that we don't accidentally
// introduce duplicate IDs, which is against the OpenAPI specification and could lead to issues.
config.DocumentFilter<CheckDuplicateOperationIdsDocumentFilter>();
Expand All @@ -34,4 +36,42 @@ public static void InitializeSwaggerFilters(
config.OperationFilter<SourceFileLineOperationFilter>();
}
}

/// <summary>
/// Builds the operation ID for an endpoint. MVC controllers produce "{controller}_{action}".
/// Minimal API endpoints carry no controller/action route values, so we fall back to the endpoint
/// name assigned via <c>.WithName(...)</c>, and finally to a deterministic id derived from the route.
/// </summary>
/// <remarks>
/// Never returns null or empty. Swashbuckle writes the selector's return value straight onto
/// <c>operation.OperationId</c> β€” it does not substitute a default when a custom selector is set β€” so a
/// null/empty id would collapse distinct endpoints onto the same id, which
/// <see cref="CheckDuplicateOperationIdsDocumentFilter"/> rejects, aborting offline spec generation.
/// </remarks>
public static string BuildOperationId(ApiDescription apiDescription)
{
apiDescription.ActionDescriptor.RouteValues.TryGetValue("controller", out var controller);
apiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action);
if (!string.IsNullOrEmpty(controller) && !string.IsNullOrEmpty(action))
{
return $"{controller}_{action}";
}

var endpointName = apiDescription.ActionDescriptor.EndpointMetadata
.OfType<IEndpointNameMetadata>()
.LastOrDefault()?.EndpointName;
if (!string.IsNullOrEmpty(endpointName))
{
return endpointName;
}

// Last resort for a Minimal API endpoint mapped without .WithName(): derive a stable id from the
// HTTP method and route template. {method}+{path} is OpenAPI's uniqueness key, so this stays unique
// per operation and keeps the generated spec valid. HttpMethod defaults to "ANY", so the result is
// never empty after sanitizing non-identifier characters.
var method = apiDescription.HttpMethod ?? "ANY";
var path = apiDescription.RelativePath ?? string.Empty;
var sanitized = new string($"{method}_{path}".Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray());
return sanitized.Trim('_');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
ο»Ώusing Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Bit.HttpExtensions.Test;

public class EndpointDataSourceServiceCollectionExtensionsTests
{
private const string SwaggerGenEnvVar = "swaggerGen";

[Fact]
public void AddOpenApiEndpointDataSource_WhenNotGenerating_IsNoOp()
{
using var _ = WithSwaggerGen(null);
var services = new ServiceCollection();

services.AddOpenApiEndpointDataSource(b => b.DataSources.Add(new StubEndpointDataSource(EndpointTestData.Make("A"))));

Assert.DoesNotContain(services, d => d.ServiceType == typeof(EndpointDataSource));
}

[Fact]
public void AddOpenApiEndpointDataSource_WhenGenerating_RegistersSingleSourceComposingAllFeatures()
{
using var _ = WithSwaggerGen("true");
var services = new ServiceCollection();

services.AddOpenApiEndpointDataSource(b => b.DataSources.Add(new StubEndpointDataSource(EndpointTestData.Make("A"))));
services.AddOpenApiEndpointDataSource(b => b.DataSources.Add(new StubEndpointDataSource(EndpointTestData.Make("B"))));

// A single EndpointDataSource is registered regardless of how many features register, otherwise
// ApiExplorer (which injects only one) would surface just the last feature's endpoints.
Assert.Single(services, d => d.ServiceType == typeof(EndpointDataSource));

var provider = services.BuildServiceProvider();
var source = provider.GetRequiredService<EndpointDataSource>();

Assert.Equal(2, source.Endpoints.Count);
Assert.Contains(source.Endpoints, e => e.DisplayName == "A");
Assert.Contains(source.Endpoints, e => e.DisplayName == "B");
}

/// <summary>
/// Temporarily overrides the <c>swaggerGen</c> environment variable the extension gates on, restoring the
/// previous value on dispose. Tests in this class run sequentially (single xUnit collection), so the
/// process-wide variable does not race between them.
/// </summary>
private static IDisposable WithSwaggerGen(string? value)
{
var previous = Environment.GetEnvironmentVariable(SwaggerGenEnvVar);
Environment.SetEnvironmentVariable(SwaggerGenEnvVar, value);
return new EnvironmentVariableScope(SwaggerGenEnvVar, previous);
}

private sealed class EnvironmentVariableScope(string name, string? previousValue) : IDisposable
{
public void Dispose() => Environment.SetEnvironmentVariable(name, previousValue);
}
}
80 changes: 80 additions & 0 deletions test/HttpExtensions.Test/StandaloneEndpointDataSourceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
ο»Ώusing Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using Xunit;

namespace Bit.HttpExtensions.Test;

public class StandaloneEndpointDataSourceTests
{
[Fact]
public void Endpoints_ComposesEveryMappingDelegate()
{
var serviceProvider = new ServiceCollection().BuildServiceProvider();
Action<IEndpointRouteBuilder> mapA = b => b.DataSources.Add(new StubEndpointDataSource(EndpointTestData.Make("A")));
Action<IEndpointRouteBuilder> mapB = b => b.DataSources.Add(new StubEndpointDataSource(EndpointTestData.Make("B")));

var source = new StandaloneEndpointDataSource(serviceProvider, [mapA, mapB]);

Assert.Equal(2, source.Endpoints.Count);
Assert.Contains(source.Endpoints, e => e.DisplayName == "A");
Assert.Contains(source.Endpoints, e => e.DisplayName == "B");
}

[Fact]
public void Endpoints_NoDelegates_IsEmpty()
{
var serviceProvider = new ServiceCollection().BuildServiceProvider();

var source = new StandaloneEndpointDataSource(serviceProvider, []);

Assert.Empty(source.Endpoints);
}

[Fact]
public void GetChangeToken_ReturnsNonNullToken()
{
var serviceProvider = new ServiceCollection().BuildServiceProvider();

var source = new StandaloneEndpointDataSource(serviceProvider, []);

Assert.NotNull(source.GetChangeToken());
}

[Fact]
public void Endpoints_MaterializesMappedMinimalApiEndpoints()
{
// End-to-end against real Minimal API mapping (not just stub data sources): the delegate maps a
// route, and StandaloneEndpointDataSource must surface it as a RouteEndpoint outside the request pipeline.
var serviceProvider = new ServiceCollection()
.AddLogging()
.AddRouting()
.BuildServiceProvider();
Action<IEndpointRouteBuilder> map = b => b.MapGet("widgets/{id}", (string id) => id).WithName("Widgets_Get");

var source = new StandaloneEndpointDataSource(serviceProvider, [map]);

var endpoint = Assert.Single(source.Endpoints);
var routeEndpoint = Assert.IsType<RouteEndpoint>(endpoint);
Assert.Equal("widgets/{id}", routeEndpoint.RoutePattern.RawText);
}
}

/// <summary>
/// Minimal <see cref="EndpointDataSource"/> exposing a fixed set of endpoints, used to drive the composition
/// logic of <see cref="StandaloneEndpointDataSource"/> without the Minimal API request-delegate machinery.
/// </summary>
internal sealed class StubEndpointDataSource(params Endpoint[] endpoints) : EndpointDataSource
{
public override IReadOnlyList<Endpoint> Endpoints { get; } = endpoints;

public override IChangeToken GetChangeToken() => new CancellationChangeToken(CancellationToken.None);
}

internal static class EndpointTestData
{
public static Endpoint Make(string displayName) =>
new(_ => Task.CompletedTask, new EndpointMetadataCollection(), displayName);
}
Loading
Loading