1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 13:23:27 +00:00

Update Swashbuckle and improve generated OpenAPI files (#6066)

* Improve generated OpenAPI files

* Nullable

* Fmt

* Correct powershell command

* Fix name

* Add some tests

* Fmt

* Switch to using json naming policy
This commit is contained in:
Daniel García
2025-08-18 18:40:50 +02:00
committed by GitHub
parent 03327cb082
commit 6971f0a976
20 changed files with 420 additions and 54 deletions

View File

@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "7.3.2",
"version": "9.0.2",
"commands": ["swagger"]
},
"dotnet-ef": {

View File

@@ -376,62 +376,23 @@ jobs:
path: docker-stub-EU.zip
if-no-files-found: error
- name: Build Public API Swagger
- name: Build Swagger files
run: |
cd ./src/Api
echo "Restore tools"
dotnet tool restore
echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../swagger.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll public
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Production
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
cd ./dev
pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: swagger.json
path: swagger.json
path: api.public.json
if-no-files-found: error
- name: Build Internal API Swagger
run: |
cd ./src/Api
echo "Restore API tools"
dotnet tool restore
echo "Publish API"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll internal
cd ../Identity
echo "Restore Identity tools"
dotnet tool restore
echo "Publish Identity"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
./obj/build-output/publish/Identity.dll v1
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Development
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: internal.json
path: internal.json
path: api.json
if-no-files-found: error
- name: Upload Identity Swagger artifact

7
.gitignore vendored
View File

@@ -129,7 +129,7 @@ publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
@@ -226,3 +226,8 @@ src/Notifications/Notifications.zip
bitwarden_license/src/Portal/Portal.zip
bitwarden_license/src/Sso/Sso.zip
**/src/**/flags.json
# Generated swagger specs
/identity.json
/api.json
/api.public.json

View File

@@ -133,6 +133,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seede
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -337,6 +339,10 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -391,6 +397,7 @@ Global
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -0,0 +1,19 @@
Set-Location "$PSScriptRoot/.."
$env:ASPNETCORE_ENVIRONMENT = "Development"
$env:swaggerGen = "True"
$env:DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX = "2"
$env:GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING = "placeholder"
dotnet tool restore
# Identity
Set-Location "./src/Identity"
dotnet build
dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1"
# Api internal & public
Set-Location "../../src/Api"
dotnet build
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"

View File

@@ -34,7 +34,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.2" />
</ItemGroup>
</Project>

View File

@@ -210,7 +210,7 @@ public class Startup
config.Conventions.Add(new PublicApiControllersModelConvention());
});
services.AddSwagger(globalSettings);
services.AddSwagger(globalSettings, Environment);
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
services.AddHostedService<Jobs.JobsHostedService>();

View File

@@ -19,7 +19,7 @@ namespace Bit.Api.Utilities;
public static class ServiceCollectionExtensions
{
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings)
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
{
services.AddSwaggerGen(config =>
{
@@ -83,6 +83,14 @@ public static class ServiceCollectionExtensions
// config.UseReferencedDefinitionsForEnums();
config.SchemaFilter<EnumSchemaFilter>();
config.SchemaFilter<EncryptedStringSchemaFilter>();
// These two filters require debug symbols/git, so only add them in development mode
if (environment.IsDevelopment())
{
config.DocumentFilter<GitCommitDocumentFilter>();
config.OperationFilter<SourceFileLineOperationFilter>();
}
var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml");
config.IncludeXmlComments(apiFilePath, true);

View File

@@ -11,7 +11,7 @@
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.2" />
</ItemGroup>
</Project>

View File

@@ -64,10 +64,19 @@ public class Startup
config.Filters.Add(new ModelStateValidationFilterAttribute());
});
services.AddSwaggerGen(c =>
services.AddSwaggerGen(config =>
{
c.SchemaFilter<EnumSchemaFilter>();
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" });
config.SchemaFilter<EnumSchemaFilter>();
config.SchemaFilter<EncryptedStringSchemaFilter>();
// These two filters require debug symbols/git, so only add them in development mode
if (Environment.IsDevelopment())
{
config.DocumentFilter<GitCommitDocumentFilter>();
config.OperationFilter<SourceFileLineOperationFilter>();
}
config.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" });
});
if (!globalSettings.SelfHosted)

View File

@@ -7,7 +7,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="7.3.2" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
#nullable enable
using System.Text.Json;
using Bit.Core.Utilities;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Bit.SharedWeb.Swagger;
/// <summary>
/// Set the format of any strings that are decorated with the <see cref="EncryptedStringAttribute"/> to "x-enc-string".
/// This will allow the generated bindings to use a more appropriate type for encrypted strings.
/// </summary>
public class EncryptedStringSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (context.Type == null || schema.Properties == null)
return;
foreach (var prop in context.Type.GetProperties())
{
// Only apply to string properties
if (prop.PropertyType != typeof(string))
continue;
// Check if the property has the EncryptedString attribute
if (prop.GetCustomAttributes(typeof(EncryptedStringAttribute), true).FirstOrDefault() != null)
{
// Convert prop.Name to camelCase for JSON schema property lookup
var jsonPropName = JsonNamingPolicy.CamelCase.ConvertName(prop.Name);
if (schema.Properties.TryGetValue(jsonPropName, out var value))
{
value.Format = "x-enc-string";
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
#nullable enable
using System.Diagnostics;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Bit.SharedWeb.Swagger;
/// <summary>
/// Add the Git commit that was used to generate the Swagger document, to help with debugging and reproducibility.
/// </summary>
public class GitCommitDocumentFilter : IDocumentFilter
{
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
if (!string.IsNullOrEmpty(GitCommit))
{
swaggerDoc.Extensions.Add("x-git-commit", new Microsoft.OpenApi.Any.OpenApiString(GitCommit));
}
}
public static string? GitCommit => _gitCommit.Value;
private static readonly Lazy<string?> _gitCommit = new(() =>
{
try
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-parse HEAD",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var result = process.StandardOutput.ReadLine()?.Trim();
process.WaitForExit();
return result ?? string.Empty;
}
catch
{
return null;
}
});
}

View File

@@ -0,0 +1,87 @@
#nullable enable
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Runtime.CompilerServices;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Bit.SharedWeb.Swagger;
/// <summary>
/// Adds source file and line number information to the Swagger operation description.
/// This can be useful for locating the source code of the operation in the repository,
/// as the generated names are based on the HTTP path, and are hard to search for.
/// </summary>
public class SourceFileLineOperationFilter : IOperationFilter
{
private static readonly string _gitCommit = GitCommitDocumentFilter.GitCommit ?? "main";
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var (fileName, lineNumber) = GetSourceFileLine(context.MethodInfo);
if (fileName != null && lineNumber > 0)
{
// Add the information with a link to the source file at the end of the operation description
operation.Description +=
$"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/{_gitCommit}/{fileName}#L{lineNumber}`]";
// Also add the information as extensions, so other tools can use it in the future
operation.Extensions.Add("x-source-file", new OpenApiString(fileName));
operation.Extensions.Add("x-source-line", new OpenApiInteger(lineNumber));
}
}
private static (string? fileName, int lineNumber) GetSourceFileLine(MethodInfo methodInfo)
{
// Get the location of the PDB file associated with the module of the method
var pdbPath = Path.ChangeExtension(methodInfo.Module.FullyQualifiedName, ".pdb");
if (!File.Exists(pdbPath)) return (null, 0);
// Open the PDB file and read the metadata
using var pdbStream = File.OpenRead(pdbPath);
using var metadataReaderProvider = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
var metadataReader = metadataReaderProvider.GetMetadataReader();
// If the method is async, the compiler will generate a state machine,
// so we can't look for the original method, but we instead need to look
// for the MoveNext method of the state machine.
var attr = methodInfo.GetCustomAttribute<AsyncStateMachineAttribute>();
if (attr?.StateMachineType != null)
{
var moveNext = attr.StateMachineType.GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (moveNext != null) methodInfo = moveNext;
}
// Once we have the method, we can get its sequence points
var handle = (MethodDefinitionHandle)MetadataTokens.Handle(methodInfo.MetadataToken);
if (handle.IsNil) return (null, 0);
var sequencePoints = metadataReader.GetMethodDebugInformation(handle).GetSequencePoints();
// Iterate through the sequence points and pick the first one that has a valid line number
foreach (var sp in sequencePoints)
{
var docName = metadataReader.GetDocument(sp.Document).Name;
if (sp.StartLine != 0 && sp.StartLine != SequencePoint.HiddenLine && !docName.IsNil)
{
var fileName = metadataReader.GetString(docName);
var repoRoot = FindRepoRoot(AppContext.BaseDirectory);
var relativeFileName = repoRoot != null ? Path.GetRelativePath(repoRoot, fileName) : fileName;
return (relativeFileName, sp.StartLine);
}
}
return (null, 0);
}
private static string? FindRepoRoot(string startPath)
{
var dir = new DirectoryInfo(startPath);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, ".git")))
dir = dir.Parent;
return dir?.FullName;
}
}

View File

@@ -0,0 +1,60 @@
using Bit.Core.Utilities;
using Bit.SharedWeb.Swagger;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SharedWeb.Test;
public class EncryptedStringSchemaFilterTest
{
private class TestClass
{
[EncryptedString]
public string SecretKey { get; set; }
public string Username { get; set; }
[EncryptedString]
public int Wrong { get; set; }
}
[Fact]
public void AnnotatedStringSetsFormat()
{
var schema = new OpenApiSchema
{
Properties = new Dictionary<string, OpenApiSchema> { { "secretKey", new() } }
};
var context = new SchemaFilterContext(typeof(TestClass), null, null, null);
var filter = new EncryptedStringSchemaFilter();
filter.Apply(schema, context);
Assert.Equal("x-enc-string", schema.Properties["secretKey"].Format);
}
[Fact]
public void NonAnnotatedStringIsIgnored()
{
var schema = new OpenApiSchema
{
Properties = new Dictionary<string, OpenApiSchema> { { "username", new() } }
};
var context = new SchemaFilterContext(typeof(TestClass), null, null, null);
var filter = new EncryptedStringSchemaFilter();
filter.Apply(schema, context);
Assert.Null(schema.Properties["username"].Format);
}
[Fact]
public void AnnotatedWrongTypeIsIgnored()
{
var schema = new OpenApiSchema
{
Properties = new Dictionary<string, OpenApiSchema> { { "wrong", new() } }
};
var context = new SchemaFilterContext(typeof(TestClass), null, null, null);
var filter = new EncryptedStringSchemaFilter();
filter.Apply(schema, context);
Assert.Null(schema.Properties["wrong"].Format);
}
}

View File

@@ -0,0 +1,41 @@
using Bit.SharedWeb.Swagger;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SharedWeb.Test;
public class EnumSchemaFilterTest
{
private enum TestEnum
{
First,
Second,
Third
}
[Fact]
public void SetsEnumVarNamesExtension()
{
var schema = new OpenApiSchema();
var context = new SchemaFilterContext(typeof(TestEnum), null, null, null);
var filter = new EnumSchemaFilter();
filter.Apply(schema, context);
Assert.True(schema.Extensions.ContainsKey("x-enum-varnames"));
var extensions = schema.Extensions["x-enum-varnames"] as OpenApiArray;
Assert.NotNull(extensions);
Assert.Equal(["First", "Second", "Third"], extensions.Select(x => ((OpenApiString)x).Value));
}
[Fact]
public void DoesNotSetExtensionForNonEnum()
{
var schema = new OpenApiSchema();
var context = new SchemaFilterContext(typeof(string), null, null, null);
var filter = new EnumSchemaFilter();
filter.Apply(schema, context);
Assert.False(schema.Extensions.ContainsKey("x-enum-varnames"));
}
}

View File

@@ -0,0 +1,23 @@
using Bit.SharedWeb.Swagger;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SharedWeb.Test;
public class GitCommitDocumentFilterTest
{
[Fact]
public void AddsGitCommitExtensionIfAvailable()
{
var doc = new OpenApiDocument();
var context = new DocumentFilterContext(null, null, null);
var filter = new GitCommitDocumentFilter();
filter.Apply(doc, context);
Assert.True(doc.Extensions.ContainsKey("x-git-commit"));
var ext = doc.Extensions["x-git-commit"] as Microsoft.OpenApi.Any.OpenApiString;
Assert.NotNull(ext);
Assert.False(string.IsNullOrEmpty(ext.Value));
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<RootNamespace>SharedWeb.Test</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio"
Version="$(XUnitRunnerVisualStudioVersion)">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SharedWeb\SharedWeb.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using Bit.SharedWeb.Swagger;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SharedWeb.Test;
public class SourceFileLineOperationFilterTest
{
private class DummyController
{
public void DummyMethod() { }
}
[Fact]
public void AddsSourceFileAndLineExtensionsIfAvailable()
{
var methodInfo = typeof(DummyController).GetMethod(nameof(DummyController.DummyMethod));
var operation = new OpenApiOperation();
var context = new OperationFilterContext(null, null, null, methodInfo);
var filter = new SourceFileLineOperationFilter();
filter.Apply(operation, context);
Assert.True(operation.Extensions.ContainsKey("x-source-file"));
Assert.True(operation.Extensions.ContainsKey("x-source-line"));
var fileExt = operation.Extensions["x-source-file"] as Microsoft.OpenApi.Any.OpenApiString;
var lineExt = operation.Extensions["x-source-line"] as Microsoft.OpenApi.Any.OpenApiInteger;
Assert.NotNull(fileExt);
Assert.NotNull(lineExt);
Assert.Equal(11, lineExt.Value);
Assert.StartsWith("test/SharedWeb.Test/", fileExt.Value);
}
}