1
0
mirror of https://github.com/bitwarden/server synced 2025-12-22 19:23:45 +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

@@ -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;
}
}