Browse Source
* Improve generated OpenAPI files * Nullable * Fmt * Correct powershell command * Fix name * Add some tests * Fmt * Switch to using json naming policypull/6217/head
20 changed files with 420 additions and 54 deletions
@ -0,0 +1,19 @@
@@ -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" |
||||
@ -0,0 +1,40 @@
@@ -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"; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,50 @@
@@ -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; |
||||
} |
||||
}); |
||||
} |
||||
@ -0,0 +1,87 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,60 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -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")); |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -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)); |
||||
|
||||
} |
||||
} |
||||
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
global using Xunit; |
||||
@ -0,0 +1,22 @@
@@ -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> |
||||
@ -0,0 +1,33 @@
@@ -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); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue