Browse Source
* Improve swagger OperationIDs: Part 1 * Fix tests and fmt * Improve docs and add more tests * Fmt * Improve Swagger OperationIDs for Auth * Fix review feedback * Use generic getcustomattributes * Format * replace swaggerexclude by split+obsolete * Format * Some remaining excludespull/6210/head
22 changed files with 583 additions and 55 deletions
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json; |
||||
using Microsoft.OpenApi.Any; |
||||
using Microsoft.OpenApi.Models; |
||||
using Swashbuckle.AspNetCore.SwaggerGen; |
||||
|
||||
namespace Bit.SharedWeb.Swagger; |
||||
|
||||
/// <summary> |
||||
/// Adds the action name (function name) as an extension to each operation in the Swagger document. |
||||
/// This can be useful for the code generation process, to generate more meaningful names for operations. |
||||
/// Note that we add both the original action name and a snake_case version, as the codegen templates |
||||
/// cannot do case conversions. |
||||
/// </summary> |
||||
public class ActionNameOperationFilter : IOperationFilter |
||||
{ |
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context) |
||||
{ |
||||
if (!context.ApiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action)) return; |
||||
if (string.IsNullOrEmpty(action)) return; |
||||
|
||||
operation.Extensions.Add("x-action-name", new OpenApiString(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 OpenApiString(JsonNamingPolicy.SnakeCaseLower.ConvertName(action))); |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
using Microsoft.OpenApi.Models; |
||||
using Swashbuckle.AspNetCore.SwaggerGen; |
||||
|
||||
namespace Bit.SharedWeb.Swagger; |
||||
|
||||
/// <summary> |
||||
/// Checks for duplicate operation IDs in the Swagger document, and throws an error if any are found. |
||||
/// Operation IDs must be unique across the entire Swagger document according to the OpenAPI specification, |
||||
/// but we use controller action names to generate them, which can lead to duplicates if a Controller function |
||||
/// has multiple HTTP methods or if a Controller has overloaded functions. |
||||
/// </summary> |
||||
public class CheckDuplicateOperationIdsDocumentFilter(bool printDuplicates = true) : IDocumentFilter |
||||
{ |
||||
public bool PrintDuplicates { get; } = printDuplicates; |
||||
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) |
||||
{ |
||||
var operationIdMap = new Dictionary<string, List<(string Path, OpenApiPathItem PathItem, OperationType Method, OpenApiOperation Operation)>>(); |
||||
|
||||
foreach (var (path, pathItem) in swaggerDoc.Paths) |
||||
{ |
||||
foreach (var operation in pathItem.Operations) |
||||
{ |
||||
if (!operationIdMap.TryGetValue(operation.Value.OperationId, out var list)) |
||||
{ |
||||
list = []; |
||||
operationIdMap[operation.Value.OperationId] = list; |
||||
} |
||||
|
||||
list.Add((path, pathItem, operation.Key, operation.Value)); |
||||
|
||||
} |
||||
} |
||||
|
||||
// Find duplicates |
||||
var duplicates = operationIdMap.Where((kvp) => kvp.Value.Count > 1).ToList(); |
||||
if (duplicates.Count > 0) |
||||
{ |
||||
if (PrintDuplicates) |
||||
{ |
||||
Console.WriteLine($"\n######## Duplicate operationIds found in the schema ({duplicates.Count} found) ########\n"); |
||||
|
||||
Console.WriteLine("## Common causes of duplicate operation IDs:"); |
||||
Console.WriteLine("- Multiple HTTP methods (GET, POST, etc.) on the same controller function"); |
||||
Console.WriteLine(" Solution: Split the methods into separate functions, and if appropiate, mark the deprecated ones with [Obsolete]"); |
||||
Console.WriteLine(); |
||||
Console.WriteLine("- Overloaded controller functions with the same name"); |
||||
Console.WriteLine(" Solution: Rename the overloaded functions to have unique names, or combine them into a single function with optional parameters"); |
||||
Console.WriteLine(); |
||||
|
||||
Console.WriteLine("## The duplicate operation IDs are:"); |
||||
|
||||
foreach (var (operationId, duplicate) in duplicates) |
||||
{ |
||||
Console.WriteLine($"- operationId: {operationId}"); |
||||
foreach (var (path, pathItem, method, operation) in duplicate) |
||||
{ |
||||
Console.Write($" {method.ToString().ToUpper()} {path}"); |
||||
|
||||
|
||||
if (operation.Extensions.TryGetValue("x-source-file", out var sourceFile) && operation.Extensions.TryGetValue("x-source-line", out var sourceLine)) |
||||
{ |
||||
var sourceFileString = ((Microsoft.OpenApi.Any.OpenApiString)sourceFile).Value; |
||||
var sourceLineString = ((Microsoft.OpenApi.Any.OpenApiInteger)sourceLine).Value; |
||||
|
||||
Console.WriteLine($" {sourceFileString}:{sourceLineString}"); |
||||
} |
||||
else |
||||
{ |
||||
Console.WriteLine(); |
||||
} |
||||
} |
||||
Console.WriteLine("\n"); |
||||
} |
||||
} |
||||
|
||||
throw new InvalidOperationException($"Duplicate operation IDs found in Swagger schema"); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Hosting; |
||||
using Microsoft.Extensions.DependencyInjection; |
||||
using Microsoft.Extensions.Hosting; |
||||
using Swashbuckle.AspNetCore.SwaggerGen; |
||||
|
||||
namespace Bit.SharedWeb.Swagger; |
||||
|
||||
public static class SwaggerGenOptionsExt |
||||
{ |
||||
|
||||
public static void InitializeSwaggerFilters( |
||||
this SwaggerGenOptions config, IWebHostEnvironment environment) |
||||
{ |
||||
config.SchemaFilter<EnumSchemaFilter>(); |
||||
config.SchemaFilter<EncryptedStringSchemaFilter>(); |
||||
|
||||
config.OperationFilter<ActionNameOperationFilter>(); |
||||
|
||||
// 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. |
||||
// TODO(Dani): This is disabled until we remove all the duplicate operation IDs. |
||||
// config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); |
||||
// config.DocumentFilter<CheckDuplicateOperationIdsDocumentFilter>(); |
||||
|
||||
// These two filters require debug symbols/git, so only add them in development mode |
||||
if (environment.IsDevelopment()) |
||||
{ |
||||
config.DocumentFilter<GitCommitDocumentFilter>(); |
||||
config.OperationFilter<SourceFileLineOperationFilter>(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
using Bit.SharedWeb.Swagger; |
||||
using Microsoft.AspNetCore.Mvc.Abstractions; |
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer; |
||||
using Microsoft.OpenApi.Any; |
||||
using Microsoft.OpenApi.Models; |
||||
using Swashbuckle.AspNetCore.SwaggerGen; |
||||
|
||||
namespace SharedWeb.Test; |
||||
|
||||
public class ActionNameOperationFilterTest |
||||
{ |
||||
[Fact] |
||||
public void WithValidActionNameAddsActionNameExtensions() |
||||
{ |
||||
// Arrange |
||||
var operation = new OpenApiOperation(); |
||||
var actionDescriptor = new ActionDescriptor(); |
||||
actionDescriptor.RouteValues["action"] = "GetUsers"; |
||||
|
||||
var apiDescription = new ApiDescription |
||||
{ |
||||
ActionDescriptor = actionDescriptor |
||||
}; |
||||
|
||||
var context = new OperationFilterContext(apiDescription, null, null, null); |
||||
var filter = new ActionNameOperationFilter(); |
||||
|
||||
// Act |
||||
filter.Apply(operation, context); |
||||
|
||||
// Assert |
||||
Assert.True(operation.Extensions.ContainsKey("x-action-name")); |
||||
Assert.True(operation.Extensions.ContainsKey("x-action-name-snake-case")); |
||||
|
||||
var actionNameExt = operation.Extensions["x-action-name"] as OpenApiString; |
||||
var actionNameSnakeCaseExt = operation.Extensions["x-action-name-snake-case"] as OpenApiString; |
||||
|
||||
Assert.NotNull(actionNameExt); |
||||
Assert.NotNull(actionNameSnakeCaseExt); |
||||
Assert.Equal("GetUsers", actionNameExt.Value); |
||||
Assert.Equal("get_users", actionNameSnakeCaseExt.Value); |
||||
} |
||||
|
||||
[Fact] |
||||
public void WithMissingActionRouteValueDoesNotAddExtensions() |
||||
{ |
||||
// Arrange |
||||
var operation = new OpenApiOperation(); |
||||
var actionDescriptor = new ActionDescriptor(); |
||||
// Not setting the "action" route value at all |
||||
|
||||
var apiDescription = new ApiDescription |
||||
{ |
||||
ActionDescriptor = actionDescriptor |
||||
}; |
||||
|
||||
var context = new OperationFilterContext(apiDescription, null, null, null); |
||||
var filter = new ActionNameOperationFilter(); |
||||
|
||||
// Act |
||||
filter.Apply(operation, context); |
||||
|
||||
// Assert |
||||
Assert.False(operation.Extensions.ContainsKey("x-action-name")); |
||||
Assert.False(operation.Extensions.ContainsKey("x-action-name-snake-case")); |
||||
} |
||||
} |
||||
@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
|
||||
using Bit.SharedWeb.Swagger; |
||||
using Microsoft.AspNetCore.Mvc; |
||||
using Microsoft.OpenApi.Models; |
||||
using Swashbuckle.AspNetCore.SwaggerGen; |
||||
|
||||
namespace SharedWeb.Test; |
||||
|
||||
public class UniqueOperationIdsController : ControllerBase |
||||
{ |
||||
[HttpGet("unique-get")] |
||||
public void UniqueGetAction() { } |
||||
|
||||
[HttpPost("unique-post")] |
||||
public void UniquePostAction() { } |
||||
} |
||||
|
||||
public class OverloadedOperationIdsController : ControllerBase |
||||
{ |
||||
[HttpPut("another-duplicate")] |
||||
public void AnotherDuplicateAction() { } |
||||
|
||||
[HttpPatch("another-duplicate/{id}")] |
||||
public void AnotherDuplicateAction(int id) { } |
||||
} |
||||
|
||||
public class MultipleHttpMethodsController : ControllerBase |
||||
{ |
||||
[HttpGet("multi-method")] |
||||
[HttpPost("multi-method")] |
||||
[HttpPut("multi-method")] |
||||
public void MultiMethodAction() { } |
||||
} |
||||
|
||||
public class CheckDuplicateOperationIdsDocumentFilterTest |
||||
{ |
||||
[Fact] |
||||
public void UniqueOperationIdsDoNotThrowException() |
||||
{ |
||||
// Arrange |
||||
var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(UniqueOperationIdsController)); |
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter(); |
||||
filter.Apply(swaggerDoc, context); |
||||
// Act & Assert |
||||
var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); |
||||
Assert.Null(exception); |
||||
} |
||||
|
||||
[Fact] |
||||
public void DuplicateOperationIdsThrowInvalidOperationException() |
||||
{ |
||||
// Arrange |
||||
var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(OverloadedOperationIdsController)); |
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter(false); |
||||
|
||||
// Act & Assert |
||||
var exception = Assert.Throws<InvalidOperationException>(() => filter.Apply(swaggerDoc, context)); |
||||
Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message); |
||||
} |
||||
|
||||
[Fact] |
||||
public void MultipleHttpMethodsThrowInvalidOperationException() |
||||
{ |
||||
// Arrange |
||||
var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(MultipleHttpMethodsController)); |
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter(false); |
||||
|
||||
// Act & Assert |
||||
var exception = Assert.Throws<InvalidOperationException>(() => filter.Apply(swaggerDoc, context)); |
||||
Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message); |
||||
} |
||||
|
||||
[Fact] |
||||
public void EmptySwaggerDocDoesNotThrowException() |
||||
{ |
||||
// Arrange |
||||
var swaggerDoc = new OpenApiDocument { Paths = [] }; |
||||
var context = new DocumentFilterContext([], null, null); |
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter(false); |
||||
|
||||
// Act & Assert |
||||
var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); |
||||
Assert.Null(exception); |
||||
} |
||||
} |
||||
@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
|
||||
using System.Reflection; |
||||
using Microsoft.AspNetCore.Hosting; |
||||
using Microsoft.AspNetCore.Mvc; |
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer; |
||||
using Microsoft.AspNetCore.Mvc.ApplicationParts; |
||||
using Microsoft.AspNetCore.Mvc.Controllers; |
||||
using Microsoft.Extensions.DependencyInjection; |
||||
using Microsoft.OpenApi.Models; |
||||
using NSubstitute; |
||||
using Swashbuckle.AspNetCore.Swagger; |
||||
using Swashbuckle.AspNetCore.SwaggerGen; |
||||
|
||||
namespace SharedWeb.Test; |
||||
|
||||
public class SwaggerDocUtil |
||||
{ |
||||
/// <summary> |
||||
/// Creates an OpenApiDocument and DocumentFilterContext from the specified controller type by setting up |
||||
/// a minimal service collection and using the SwaggerProvider to generate the document. |
||||
/// </summary> |
||||
public static (OpenApiDocument, DocumentFilterContext) CreateDocFromControllers(params Type[] controllerTypes) |
||||
{ |
||||
if (controllerTypes.Length == 0) |
||||
{ |
||||
throw new ArgumentException("At least one controller type must be provided", nameof(controllerTypes)); |
||||
} |
||||
|
||||
var services = new ServiceCollection(); |
||||
services.AddLogging(); |
||||
services.AddSingleton(Substitute.For<IWebHostEnvironment>()); |
||||
services.AddControllers() |
||||
.ConfigureApplicationPartManager(manager => |
||||
{ |
||||
// Clear existing parts and feature providers |
||||
manager.ApplicationParts.Clear(); |
||||
manager.FeatureProviders.Clear(); |
||||
|
||||
// Add a custom feature provider that only includes the specific controller types |
||||
manager.FeatureProviders.Add(new MultipleControllerFeatureProvider(controllerTypes)); |
||||
|
||||
// Add assembly parts for all unique assemblies containing the controllers |
||||
foreach (var assembly in controllerTypes.Select(t => t.Assembly).Distinct()) |
||||
{ |
||||
manager.ApplicationParts.Add(new AssemblyPart(assembly)); |
||||
} |
||||
}); |
||||
services.AddSwaggerGen(config => |
||||
{ |
||||
config.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "v1" }); |
||||
config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); |
||||
}); |
||||
var serviceProvider = services.BuildServiceProvider(); |
||||
|
||||
// Get API descriptions |
||||
var allApiDescriptions = serviceProvider.GetRequiredService<IApiDescriptionGroupCollectionProvider>() |
||||
.ApiDescriptionGroups.Items |
||||
.SelectMany(group => group.Items) |
||||
.ToList(); |
||||
|
||||
if (allApiDescriptions.Count == 0) |
||||
{ |
||||
throw new InvalidOperationException("No API descriptions found for controller, ensure your controllers are defined correctly (public, not nested, inherit from ControllerBase, etc.)"); |
||||
} |
||||
|
||||
// Generate the swagger document and context |
||||
var document = serviceProvider.GetRequiredService<ISwaggerProvider>().GetSwagger("v1"); |
||||
var schemaGenerator = serviceProvider.GetRequiredService<ISchemaGenerator>(); |
||||
var context = new DocumentFilterContext(allApiDescriptions, schemaGenerator, new SchemaRepository()); |
||||
|
||||
return (document, context); |
||||
} |
||||
} |
||||
|
||||
public class MultipleControllerFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider |
||||
{ |
||||
private readonly HashSet<Type> _allowedControllerTypes = [.. controllerTypes]; |
||||
|
||||
protected override bool IsController(TypeInfo typeInfo) |
||||
{ |
||||
return _allowedControllerTypes.Contains(typeInfo.AsType()) |
||||
&& typeInfo.IsClass |
||||
&& !typeInfo.IsAbstract |
||||
&& typeof(ControllerBase).IsAssignableFrom(typeInfo); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue