diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d7814849c6..41674ccad0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "7.3.2", + "version": "9.0.2", "commands": ["swagger"] }, "dotnet-ef": { diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54c31ee6ea..1d08145b5a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index e1b2153433..3b1f40e673 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 2ec8d86e0e..dbc37372a1 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -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} diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1 new file mode 100644 index 0000000000..02470a0b1d --- /dev/null +++ b/dev/generate_openapi_files.ps1 @@ -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" diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 11af4d5e0a..d48f49626f 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 699fa3f804..450cb64bad 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -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(); diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 4f123d3f4f..aa2710c42a 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -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(); + config.SchemaFilter(); + + // These two filters require debug symbols/git, so only add them in development mode + if (environment.IsDevelopment()) + { + config.DocumentFilter(); + config.OperationFilter(); + } var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml"); config.IncludeXmlComments(apiFilePath, true); diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 25327b17b7..18c627c5de 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index baaf9385af..ae628197e8 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -64,10 +64,19 @@ public class Startup config.Filters.Add(new ModelStateValidationFilterAttribute()); }); - services.AddSwaggerGen(c => + services.AddSwaggerGen(config => { - c.SchemaFilter(); - c.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" }); + config.SchemaFilter(); + config.SchemaFilter(); + + // These two filters require debug symbols/git, so only add them in development mode + if (Environment.IsDevelopment()) + { + config.DocumentFilter(); + config.OperationFilter(); + } + + config.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" }); }); if (!globalSettings.SelfHosted) diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 1951e4d509..445b98cce0 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs b/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs new file mode 100644 index 0000000000..d26ae58e59 --- /dev/null +++ b/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs @@ -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; + +/// +/// Set the format of any strings that are decorated with the to "x-enc-string". +/// This will allow the generated bindings to use a more appropriate type for encrypted strings. +/// +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"; + } + } + } + } +} diff --git a/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs b/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs new file mode 100644 index 0000000000..86678722ce --- /dev/null +++ b/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs @@ -0,0 +1,50 @@ +#nullable enable + +using System.Diagnostics; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Add the Git commit that was used to generate the Swagger document, to help with debugging and reproducibility. +/// +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 _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; + } + }); +} diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs new file mode 100644 index 0000000000..cbad1e9736 --- /dev/null +++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs @@ -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; + +/// +/// 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. +/// +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(); + 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; + } +} diff --git a/test/SharedWeb.Test/EncryptedStringSchemaFilterTest.cs b/test/SharedWeb.Test/EncryptedStringSchemaFilterTest.cs new file mode 100644 index 0000000000..172ddf5ee5 --- /dev/null +++ b/test/SharedWeb.Test/EncryptedStringSchemaFilterTest.cs @@ -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 { { "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 { { "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 { { "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); + } +} diff --git a/test/SharedWeb.Test/EnumSchemaFilterTest.cs b/test/SharedWeb.Test/EnumSchemaFilterTest.cs new file mode 100644 index 0000000000..b0c14437c1 --- /dev/null +++ b/test/SharedWeb.Test/EnumSchemaFilterTest.cs @@ -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")); + } +} diff --git a/test/SharedWeb.Test/GitCommitDocumentFilterTest.cs b/test/SharedWeb.Test/GitCommitDocumentFilterTest.cs new file mode 100644 index 0000000000..542ef888f9 --- /dev/null +++ b/test/SharedWeb.Test/GitCommitDocumentFilterTest.cs @@ -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)); + + } +} diff --git a/test/SharedWeb.Test/GlobalUsings.cs b/test/SharedWeb.Test/GlobalUsings.cs new file mode 100644 index 0000000000..9df1d42179 --- /dev/null +++ b/test/SharedWeb.Test/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/SharedWeb.Test/SharedWeb.Test.csproj b/test/SharedWeb.Test/SharedWeb.Test.csproj new file mode 100644 index 0000000000..8ae7a56a99 --- /dev/null +++ b/test/SharedWeb.Test/SharedWeb.Test.csproj @@ -0,0 +1,22 @@ + + + false + SharedWeb.Test + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + diff --git a/test/SharedWeb.Test/SourceFileLineOperationFilterTest.cs b/test/SharedWeb.Test/SourceFileLineOperationFilterTest.cs new file mode 100644 index 0000000000..98da92c8c1 --- /dev/null +++ b/test/SharedWeb.Test/SourceFileLineOperationFilterTest.cs @@ -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); + } +}