mirror of
https://github.com/bitwarden/server
synced 2025-12-10 21:33:41 +00:00
[PM-25182] Improve swagger OperationIDs: Part 1 (#6229)
* 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 excludes
This commit is contained in:
@@ -43,7 +43,7 @@ public class AuthRequestsControllerTests
|
||||
.Returns([authRequest]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Get();
|
||||
var result = await sutProvider.Sut.GetAll();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -73,7 +73,7 @@ public class DevicesControllerTest
|
||||
_deviceRepositoryMock.GetManyByUserIdWithDeviceAuth(userId).Returns(devicesWithPendingAuthData);
|
||||
|
||||
// Act
|
||||
var result = await _sut.Get();
|
||||
var result = await _sut.GetAll();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
@@ -94,6 +94,6 @@ public class DevicesControllerTest
|
||||
_userServiceMock.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>()).Returns((Guid?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.Get());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.GetAll());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ public class CollectionsControllerTests
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(organization.Id)
|
||||
.Returns(collections);
|
||||
|
||||
var response = await sutProvider.Sut.Get(organization.Id);
|
||||
var response = await sutProvider.Sut.GetAll(organization.Id);
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManySharedCollectionsByOrganizationIdAsync(organization.Id);
|
||||
|
||||
@@ -219,7 +219,7 @@ public class CollectionsControllerTests
|
||||
.GetManyByUserIdAsync(userId)
|
||||
.Returns(collections);
|
||||
|
||||
var result = await sutProvider.Sut.Get(organization.Id);
|
||||
var result = await sutProvider.Sut.GetAll(organization.Id);
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceive().GetManyByOrganizationIdAsync(organization.Id);
|
||||
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManyByUserIdAsync(userId);
|
||||
|
||||
67
test/SharedWeb.Test/ActionNameOperationFilterTest.cs
Normal file
67
test/SharedWeb.Test/ActionNameOperationFilterTest.cs
Normal file
@@ -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 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio"
|
||||
Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
|
||||
85
test/SharedWeb.Test/SwaggerDocUtil.cs
Normal file
85
test/SharedWeb.Test/SwaggerDocUtil.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user