diff --git a/src/Microsoft.OpenApi.Hidi/Handlers/ShowCommandHandler.cs b/src/Microsoft.OpenApi.Hidi/Handlers/ShowCommandHandler.cs new file mode 100644 index 000000000..6974e76dd --- /dev/null +++ b/src/Microsoft.OpenApi.Hidi/Handlers/ShowCommandHandler.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.OpenApi.Hidi.Handlers +{ + internal class ShowCommandHandler : ICommandHandler + { + public Option DescriptionOption { get; set; } + public Option OutputOption { get; set; } + public Option LogLevelOption { get; set; } + public Option CsdlOption { get; set; } + public Option CsdlFilterOption { get; set; } + + + public int Invoke(InvocationContext context) + { + return InvokeAsync(context).GetAwaiter().GetResult(); + } + public async Task InvokeAsync(InvocationContext context) + { + string openapi = context.ParseResult.GetValueForOption(DescriptionOption); + FileInfo output = context.ParseResult.GetValueForOption(OutputOption); + LogLevel logLevel = context.ParseResult.GetValueForOption(LogLevelOption); + string csdlFilter = context.ParseResult.GetValueForOption(CsdlFilterOption); + string csdl = context.ParseResult.GetValueForOption(CsdlOption); + CancellationToken cancellationToken = (CancellationToken)context.BindingContext.GetService(typeof(CancellationToken)); + + using var loggerFactory = Logger.ConfigureLogger(logLevel); + var logger = loggerFactory.CreateLogger(); + try + { + await OpenApiService.ShowOpenApiDocument(openapi, csdl, csdlFilter, output, logger, cancellationToken); + + return 0; + } + catch (Exception ex) + { +#if DEBUG + logger.LogCritical(ex, ex.Message); + throw; // so debug tools go straight to the source of the exception when attached +#else + logger.LogCritical( ex.Message); + return 1; +#endif + } + } + } +} diff --git a/src/Microsoft.OpenApi.Hidi/Handlers/TransformCommandHandler.cs b/src/Microsoft.OpenApi.Hidi/Handlers/TransformCommandHandler.cs index e46b34340..d0a49c209 100644 --- a/src/Microsoft.OpenApi.Hidi/Handlers/TransformCommandHandler.cs +++ b/src/Microsoft.OpenApi.Hidi/Handlers/TransformCommandHandler.cs @@ -57,7 +57,7 @@ public async Task InvokeAsync(InvocationContext context) var logger = loggerFactory.CreateLogger(); try { - await OpenApiService.TransformOpenApiDocument(openapi, csdl, csdlFilter, output, cleanOutput, version, format, terseOutput, settingsFile, logLevel, inlineLocal, inlineExternal, filterbyoperationids, filterbytags, filterbycollection, cancellationToken); + await OpenApiService.TransformOpenApiDocument(openapi, csdl, csdlFilter, output, cleanOutput, version, format, terseOutput, settingsFile, inlineLocal, inlineExternal, filterbyoperationids, filterbytags, filterbycollection, logger, cancellationToken); return 0; } diff --git a/src/Microsoft.OpenApi.Hidi/Handlers/ValidateCommandHandler.cs b/src/Microsoft.OpenApi.Hidi/Handlers/ValidateCommandHandler.cs index 2faa771ea..416471d9e 100644 --- a/src/Microsoft.OpenApi.Hidi/Handlers/ValidateCommandHandler.cs +++ b/src/Microsoft.OpenApi.Hidi/Handlers/ValidateCommandHandler.cs @@ -30,7 +30,7 @@ public async Task InvokeAsync(InvocationContext context) var logger = loggerFactory.CreateLogger(); try { - await OpenApiService.ValidateOpenApiDocument(openapi, logLevel, cancellationToken); + await OpenApiService.ValidateOpenApiDocument(openapi, logger, cancellationToken); return 0; } catch (Exception ex) diff --git a/src/Microsoft.OpenApi.Hidi/OpenApiService.cs b/src/Microsoft.OpenApi.Hidi/OpenApiService.cs index 60bba4aef..e63a2b9ba 100644 --- a/src/Microsoft.OpenApi.Hidi/OpenApiService.cs +++ b/src/Microsoft.OpenApi.Hidi/OpenApiService.cs @@ -28,7 +28,6 @@ using System.Xml; using System.Reflection; using Microsoft.Extensions.Configuration; -using System.Runtime.CompilerServices; namespace Microsoft.OpenApi.Hidi { @@ -47,28 +46,28 @@ public static async Task TransformOpenApiDocument( OpenApiFormat? format, bool terseOutput, string settingsFile, - LogLevel logLevel, bool inlineLocal, bool inlineExternal, string filterbyoperationids, string filterbytags, string filterbycollection, + ILogger logger, CancellationToken cancellationToken ) { - using var loggerFactory = Logger.ConfigureLogger(logLevel); - var logger = loggerFactory.CreateLogger(); + if (string.IsNullOrEmpty(openapi) && string.IsNullOrEmpty(csdl)) + { + throw new ArgumentException("Please input a file path or URL"); + } + try { - if (string.IsNullOrEmpty(openapi) && string.IsNullOrEmpty(csdl)) - { - throw new ArgumentException("Please input a file path"); - } if (output == null) { var inputExtension = GetInputPathExtension(openapi, csdl); output = new FileInfo($"./output{inputExtension}"); }; + if (cleanoutput && output.Exists) { output.Delete(); @@ -78,152 +77,140 @@ CancellationToken cancellationToken throw new IOException($"The file {output} already exists. Please input a new file path."); } - Stream stream; - OpenApiDocument document; - OpenApiFormat openApiFormat; - OpenApiSpecVersion openApiVersion; - var stopwatch = new Stopwatch(); - - if (!string.IsNullOrEmpty(csdl)) - { - using (logger.BeginScope($"Convert CSDL: {csdl}", csdl)) - { - stopwatch.Start(); - // Default to yaml and OpenApiVersion 3 during csdl to OpenApi conversion - openApiFormat = format ?? GetOpenApiFormat(csdl, logger); - openApiVersion = version != null ? TryParseOpenApiSpecVersion(version) : OpenApiSpecVersion.OpenApi3_0; + // Default to yaml and OpenApiVersion 3 during csdl to OpenApi conversion + OpenApiFormat openApiFormat = format ?? (!string.IsNullOrEmpty(openapi) ? GetOpenApiFormat(openapi, logger) : OpenApiFormat.Yaml); + OpenApiSpecVersion openApiVersion = version != null ? TryParseOpenApiSpecVersion(version) : OpenApiSpecVersion.OpenApi3_0; - stream = await GetStream(csdl, logger, cancellationToken); + OpenApiDocument document = await GetOpenApi(openapi, csdl, csdlFilter, settingsFile, inlineExternal, logger, cancellationToken); + document = await FilterOpenApiDocument(filterbyoperationids, filterbytags, filterbycollection, document, logger, cancellationToken); + WriteOpenApi(output, terseOutput, inlineLocal, inlineExternal, openApiFormat, openApiVersion, document, logger); + } + catch (TaskCanceledException) + { + Console.Error.WriteLine("CTRL+C pressed, aborting the operation."); + } + catch (IOException) + { + throw; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Could not transform the document, reason: {ex.Message}", ex); + } + } - if (!string.IsNullOrEmpty(csdlFilter)) - { - XslCompiledTransform transform = GetFilterTransform(); - stream = ApplyFilter(csdl, csdlFilter, transform); - stream.Position = 0; - } + private static void WriteOpenApi(FileInfo output, bool terseOutput, bool inlineLocal, bool inlineExternal, OpenApiFormat openApiFormat, OpenApiSpecVersion openApiVersion, OpenApiDocument document, ILogger logger) + { + using (logger.BeginScope("Output")) + { + using var outputStream = output.Create(); + var textWriter = new StreamWriter(outputStream); - document = await ConvertCsdlToOpenApi(stream, settingsFile, cancellationToken); - stopwatch.Stop(); - logger.LogTrace("{timestamp}ms: Generated OpenAPI with {paths} paths.", stopwatch.ElapsedMilliseconds, document.Paths.Count); - } - } - else + var settings = new OpenApiWriterSettings() { - stream = await GetStream(openapi, logger, cancellationToken); - - using (logger.BeginScope($"Parse OpenAPI: {openapi}",openapi)) - { - stopwatch.Restart(); - var result = await new OpenApiStreamReader(new OpenApiReaderSettings - { - RuleSet = ValidationRuleSet.GetDefaultRuleSet(), - LoadExternalRefs = inlineExternal, - BaseUrl = openapi.StartsWith("http") ? new Uri(openapi) : new Uri("file:" + new FileInfo(openapi).DirectoryName + "\\") - } - ).ReadAsync(stream); + InlineLocalReferences = inlineLocal, + InlineExternalReferences = inlineExternal + }; - document = result.OpenApiDocument; + IOpenApiWriter writer = openApiFormat switch + { + OpenApiFormat.Json => terseOutput ? new OpenApiJsonWriter(textWriter, settings, terseOutput) : new OpenApiJsonWriter(textWriter, settings, false), + OpenApiFormat.Yaml => new OpenApiYamlWriter(textWriter, settings), + _ => throw new ArgumentException("Unknown format"), + }; - var context = result.OpenApiDiagnostic; - if (context.Errors.Count > 0) - { - logger.LogTrace("{timestamp}ms: Parsed OpenAPI with errors. {count} paths found.", stopwatch.ElapsedMilliseconds, document.Paths.Count); + logger.LogTrace("Serializing to OpenApi document using the provided spec version and writer"); - var errorReport = new StringBuilder(); + var stopwatch = new Stopwatch(); + stopwatch.Start(); + document.Serialize(writer, openApiVersion); + stopwatch.Stop(); - foreach (var error in context.Errors) - { - logger.LogError("OpenApi Parsing error: {message}", error.ToString()); - errorReport.AppendLine(error.ToString()); - } - logger.LogError($"{stopwatch.ElapsedMilliseconds}ms: OpenApi Parsing errors {string.Join(Environment.NewLine, context.Errors.Select(e => e.Message).ToArray())}"); - } - else - { - logger.LogTrace("{timestamp}ms: Parsed OpenApi successfully. {count} paths found.", stopwatch.ElapsedMilliseconds, document.Paths.Count); - } + logger.LogTrace($"Finished serializing in {stopwatch.ElapsedMilliseconds}ms"); + textWriter.Flush(); + } + } - openApiFormat = format ?? GetOpenApiFormat(openapi, logger); - openApiVersion = version != null ? TryParseOpenApiSpecVersion(version) : result.OpenApiDiagnostic.SpecificationVersion; - stopwatch.Stop(); - } - } + // Get OpenAPI document either from OpenAPI or CSDL + private static async Task GetOpenApi(string openapi, string csdl, string csdlFilter, string settingsFile, bool inlineExternal, ILogger logger, CancellationToken cancellationToken) + { + OpenApiDocument document; + Stream stream; - using (logger.BeginScope("Filter")) + if (!string.IsNullOrEmpty(csdl)) + { + var stopwatch = new Stopwatch(); + using (logger.BeginScope($"Convert CSDL: {csdl}", csdl)) { - Func predicate = null; - - // Check if filter options are provided, then slice the OpenAPI document - if (!string.IsNullOrEmpty(filterbyoperationids) && !string.IsNullOrEmpty(filterbytags)) + stopwatch.Start(); + stream = await GetStream(csdl, logger, cancellationToken); + Stream filteredStream = null; + if (!string.IsNullOrEmpty(csdlFilter)) { - throw new InvalidOperationException("Cannot filter by operationIds and tags at the same time."); + XslCompiledTransform transform = GetFilterTransform(); + filteredStream = ApplyFilterToCsdl(stream, csdlFilter, transform); + filteredStream.Position = 0; + stream.Dispose(); + stream = null; } - if (!string.IsNullOrEmpty(filterbyoperationids)) - { - logger.LogTrace("Creating predicate based on the operationIds supplied."); - predicate = OpenApiFilterService.CreatePredicate(operationIds: filterbyoperationids); - } - if (!string.IsNullOrEmpty(filterbytags)) - { - logger.LogTrace("Creating predicate based on the tags supplied."); - predicate = OpenApiFilterService.CreatePredicate(tags: filterbytags); + document = await ConvertCsdlToOpenApi(filteredStream ?? stream, settingsFile, cancellationToken); + stopwatch.Stop(); + logger.LogTrace("{timestamp}ms: Generated OpenAPI with {paths} paths.", stopwatch.ElapsedMilliseconds, document.Paths.Count); + } + } + else + { + stream = await GetStream(openapi, logger, cancellationToken); + var result = await ParseOpenApi(openapi, inlineExternal, logger, stream); + document = result.OpenApiDocument; + } - } - if (!string.IsNullOrEmpty(filterbycollection)) - { - var fileStream = await GetStream(filterbycollection, logger, cancellationToken); - var requestUrls = ParseJsonCollectionFile(fileStream, logger); + return document; + } - logger.LogTrace("Creating predicate based on the paths and Http methods defined in the Postman collection."); - predicate = OpenApiFilterService.CreatePredicate(requestUrls: requestUrls, source: document); - } - if (predicate != null) - { - stopwatch.Restart(); - document = OpenApiFilterService.CreateFilteredDocument(document, predicate); - stopwatch.Stop(); - logger.LogTrace("{timestamp}ms: Creating filtered OpenApi document with {paths} paths.", stopwatch.ElapsedMilliseconds, document.Paths.Count); - } - } + private static async Task FilterOpenApiDocument(string filterbyoperationids, string filterbytags, string filterbycollection, OpenApiDocument document, ILogger logger, CancellationToken cancellationToken) + { + using (logger.BeginScope("Filter")) + { + Func predicate = null; - using (logger.BeginScope("Output")) + // Check if filter options are provided, then slice the OpenAPI document + if (!string.IsNullOrEmpty(filterbyoperationids) && !string.IsNullOrEmpty(filterbytags)) { - ; - using var outputStream = output?.Create(); - var textWriter = outputStream != null ? new StreamWriter(outputStream) : Console.Out; - - var settings = new OpenApiWriterSettings() - { - InlineLocalReferences = inlineLocal, - InlineExternalReferences = inlineExternal - }; + throw new InvalidOperationException("Cannot filter by operationIds and tags at the same time."); + } + if (!string.IsNullOrEmpty(filterbyoperationids)) + { + logger.LogTrace("Creating predicate based on the operationIds supplied."); + predicate = OpenApiFilterService.CreatePredicate(operationIds: filterbyoperationids); - IOpenApiWriter writer = openApiFormat switch - { - OpenApiFormat.Json => terseOutput ? new OpenApiJsonWriter(textWriter, settings, terseOutput) : new OpenApiJsonWriter(textWriter, settings, false), - OpenApiFormat.Yaml => new OpenApiYamlWriter(textWriter, settings), - _ => throw new ArgumentException("Unknown format"), - }; + } + if (!string.IsNullOrEmpty(filterbytags)) + { + logger.LogTrace("Creating predicate based on the tags supplied."); + predicate = OpenApiFilterService.CreatePredicate(tags: filterbytags); - logger.LogTrace("Serializing to OpenApi document using the provided spec version and writer"); + } + if (!string.IsNullOrEmpty(filterbycollection)) + { + var fileStream = await GetStream(filterbycollection, logger, cancellationToken); + var requestUrls = ParseJsonCollectionFile(fileStream, logger); + logger.LogTrace("Creating predicate based on the paths and Http methods defined in the Postman collection."); + predicate = OpenApiFilterService.CreatePredicate(requestUrls: requestUrls, source: document); + } + if (predicate != null) + { + var stopwatch = new Stopwatch(); stopwatch.Start(); - document.Serialize(writer, openApiVersion); + document = OpenApiFilterService.CreateFilteredDocument(document, predicate); stopwatch.Stop(); - - logger.LogTrace($"Finished serializing in {stopwatch.ElapsedMilliseconds}ms"); - textWriter.Flush(); + logger.LogTrace("{timestamp}ms: Creating filtered OpenApi document with {paths} paths.", stopwatch.ElapsedMilliseconds, document.Paths.Count); } } - catch(TaskCanceledException) - { - Console.Error.WriteLine("CTRL+C pressed, aborting the operation."); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Could not transform the document, reason: {ex.Message}", ex); - } + + return document; } private static XslCompiledTransform GetFilterTransform() @@ -235,10 +222,10 @@ private static XslCompiledTransform GetFilterTransform() return transform; } - private static Stream ApplyFilter(string csdl, string entitySetOrSingleton, XslCompiledTransform transform) + private static Stream ApplyFilterToCsdl(Stream csdlStream, string entitySetOrSingleton, XslCompiledTransform transform) { Stream stream; - StreamReader inputReader = new(csdl); + using StreamReader inputReader = new(csdlStream, leaveOpen: true); XmlReader inputXmlReader = XmlReader.Create(inputReader); MemoryStream filteredStream = new(); StreamWriter writer = new(filteredStream); @@ -254,64 +241,65 @@ private static Stream ApplyFilter(string csdl, string entitySetOrSingleton, XslC /// public static async Task ValidateOpenApiDocument( string openapi, - LogLevel logLevel, + ILogger logger, CancellationToken cancellationToken) { - using var loggerFactory = Logger.ConfigureLogger(logLevel); - var logger = loggerFactory.CreateLogger(); - try + if (string.IsNullOrEmpty(openapi)) { - if (string.IsNullOrEmpty(openapi)) - { - throw new ArgumentNullException(nameof(openapi)); - } - var stream = await GetStream(openapi, logger, cancellationToken); - - OpenApiDocument document; - Stopwatch stopwatch = Stopwatch.StartNew(); - using (logger.BeginScope($"Parsing OpenAPI: {openapi}", openapi)) - { - stopwatch.Start(); + throw new ArgumentNullException(nameof(openapi)); + } - var result = await new OpenApiStreamReader(new OpenApiReaderSettings - { - RuleSet = ValidationRuleSet.GetDefaultRuleSet() - } - ).ReadAsync(stream); - - logger.LogTrace("{timestamp}ms: Completed parsing.", stopwatch.ElapsedMilliseconds); + try + { + using var stream = await GetStream(openapi, logger, cancellationToken); - document = result.OpenApiDocument; - var context = result.OpenApiDiagnostic; - if (context.Errors.Count != 0) - { - using (logger.BeginScope("Detected errors")) - { - foreach (var error in context.Errors) - { - logger.LogError(error.ToString()); - } - } - } - stopwatch.Stop(); - } + var result = await ParseOpenApi(openapi, false, logger, stream); using (logger.BeginScope("Calculating statistics")) { var statsVisitor = new StatsVisitor(); var walker = new OpenApiWalker(statsVisitor); - walker.Walk(document); + walker.Walk(result.OpenApiDocument); logger.LogTrace("Finished walking through the OpenApi document. Generating a statistics report.."); logger.LogInformation(statsVisitor.GetStatisticsReport()); } } + catch (TaskCanceledException) + { + Console.Error.WriteLine("CTRL+C pressed, aborting the operation."); + } catch (Exception ex) { throw new InvalidOperationException($"Could not validate the document, reason: {ex.Message}", ex); } } + private static async Task ParseOpenApi(string openApiFile, bool inlineExternal, ILogger logger, Stream stream) + { + ReadResult result; + Stopwatch stopwatch = Stopwatch.StartNew(); + using (logger.BeginScope($"Parsing OpenAPI: {openApiFile}", openApiFile)) + { + stopwatch.Start(); + + result = await new OpenApiStreamReader(new OpenApiReaderSettings + { + RuleSet = ValidationRuleSet.GetDefaultRuleSet(), + LoadExternalRefs = inlineExternal, + BaseUrl = openApiFile.StartsWith("http") ? new Uri(openApiFile) : new Uri("file:" + new FileInfo(openApiFile).DirectoryName + "\\") + } + ).ReadAsync(stream); + + logger.LogTrace("{timestamp}ms: Completed parsing.", stopwatch.ElapsedMilliseconds); + + LogErrors(logger, result); + stopwatch.Stop(); + } + + return result; + } + internal static IConfiguration GetConfiguration(string settingsFile) { settingsFile ??= "appsettings.json"; @@ -517,23 +505,152 @@ private static string GetInputPathExtension(string openapi = null, string csdl = return extension; } - private static ILoggerFactory ConfigureLoggerInstance(LogLevel loglevel) + internal static async Task ShowOpenApiDocument(string openapi, string csdl, string csdlFilter, FileInfo output, ILogger logger, CancellationToken cancellationToken) { - // Configure logger options -#if DEBUG - loglevel = loglevel > LogLevel.Debug ? LogLevel.Debug : loglevel; -#endif - - return Microsoft.Extensions.Logging.LoggerFactory.Create((builder) => { - builder - .AddSimpleConsole(c => { - c.IncludeScopes = true; - }) -#if DEBUG - .AddDebug() -#endif - .SetMinimumLevel(loglevel); - }); + try + { + if (string.IsNullOrEmpty(openapi) && string.IsNullOrEmpty(csdl)) + { + throw new ArgumentException("Please input a file path or URL"); + } + + var document = await GetOpenApi(openapi, csdl, csdlFilter, null, false, logger, cancellationToken); + + using (logger.BeginScope("Creating diagram")) + { + // If output is null, create a HTML file in the user's temporary directory + if (output == null) + { + var tempPath = Path.GetTempPath() + "/hidi/"; + if(!File.Exists(tempPath)) + { + Directory.CreateDirectory(tempPath); + } + + var fileName = Path.GetRandomFileName(); + + output = new FileInfo(Path.Combine(tempPath, fileName + ".html")); + using (var file = new FileStream(output.FullName, FileMode.Create)) + { + using var writer = new StreamWriter(file); + WriteTreeDocumentAsHtml(openapi ?? csdl, document, writer); + } + logger.LogTrace("Created Html document with diagram "); + + // Launch a browser to display the output html file + using var process = new Process(); + process.StartInfo.FileName = output.FullName; + process.StartInfo.UseShellExecute = true; + process.Start(); + + return output.FullName; + } + else // Write diagram as Markdown document to output file + { + using (var file = new FileStream(output.FullName, FileMode.Create)) + { + using var writer = new StreamWriter(file); + WriteTreeDocumentAsMarkdown(openapi ?? csdl, document, writer); + } + logger.LogTrace("Created markdown document with diagram "); + return output.FullName; + } + } + } + catch (TaskCanceledException) + { + Console.Error.WriteLine("CTRL+C pressed, aborting the operation."); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Could not generate the document, reason: {ex.Message}", ex); + } + return null; + } + + private static void LogErrors(ILogger logger, ReadResult result) + { + var context = result.OpenApiDiagnostic; + if (context.Errors.Count != 0) + { + using (logger.BeginScope("Detected errors")) + { + foreach (var error in context.Errors) + { + logger.LogError($"Detected error during parsing: {error}",error.ToString()); + } + } + } + } + + internal static void WriteTreeDocumentAsMarkdown(string openapiUrl, OpenApiDocument document, StreamWriter writer) + { + var rootNode = OpenApiUrlTreeNode.Create(document, "main"); + + writer.WriteLine("# " + document.Info.Title); + writer.WriteLine(); + writer.WriteLine("API Description: " + openapiUrl); + + writer.WriteLine(@"
"); + // write a span for each mermaidcolorscheme + foreach (var style in OpenApiUrlTreeNode.MermaidNodeStyles) + { + writer.WriteLine($"{style.Key.Replace("_"," ")}"); + } + writer.WriteLine("
"); + writer.WriteLine(); + writer.WriteLine("```mermaid"); + rootNode.WriteMermaid(writer); + writer.WriteLine("```"); + } + + internal static void WriteTreeDocumentAsHtml(string sourceUrl, OpenApiDocument document, StreamWriter writer, bool asHtmlFile = false) + { + var rootNode = OpenApiUrlTreeNode.Create(document, "main"); + + writer.WriteLine(@" + + + + + + +"); + writer.WriteLine("

" + document.Info.Title + "

"); + writer.WriteLine(); + writer.WriteLine($"

API Description: {sourceUrl}

"); + + writer.WriteLine(@"
"); + // write a span for each mermaidcolorscheme + foreach (var style in OpenApiUrlTreeNode.MermaidNodeStyles) + { + writer.WriteLine($"{style.Key.Replace("_", " ")}"); + } + writer.WriteLine("
"); + writer.WriteLine("
"); + writer.WriteLine(""); + rootNode.WriteMermaid(writer); + writer.WriteLine(""); + + // Write script tag to include JS library for rendering markdown + writer.WriteLine(@""); + // Write script tag to include JS library for rendering mermaid + writer.WriteLine("("--openapi", "Input OpenAPI description file path or URL"); @@ -46,7 +55,7 @@ static async Task Main(string[] args) var settingsFileOption = new Option("--settings-path", "The configuration file with CSDL conversion settings."); settingsFileOption.AddAlias("--sp"); - + var logLevelOption = new Option("--log-level", () => LogLevel.Information, "The log level to use when logging messages to the main output."); logLevelOption.AddAlias("--ll"); @@ -71,7 +80,7 @@ static async Task Main(string[] args) logLevelOption }; - validateCommand.Handler = new ValidateCommandHandler + validateCommand.Handler = new ValidateCommandHandler { DescriptionOption = descriptionOption, LogLevelOption = logLevelOption @@ -88,7 +97,7 @@ static async Task Main(string[] args) formatOption, terseOutputOption, settingsFileOption, - logLevelOption, + logLevelOption, filterByOperationIdsOption, filterByTagsOption, filterByCollectionOption, @@ -115,14 +124,29 @@ static async Task Main(string[] args) InlineExternalOption = inlineExternalOption }; + var showCommand = new Command("show") + { + descriptionOption, + csdlOption, + csdlFilterOption, + logLevelOption, + outputOption, + cleanOutputOption + }; + + showCommand.Handler = new ShowCommandHandler + { + DescriptionOption = descriptionOption, + CsdlOption = csdlOption, + CsdlFilterOption = csdlFilterOption, + OutputOption = outputOption, + LogLevelOption = logLevelOption + }; + + rootCommand.Add(showCommand); rootCommand.Add(transformCommand); rootCommand.Add(validateCommand); - - // Parse the incoming args and invoke the handler - await rootCommand.InvokeAsync(args); - - //// Wait for logger to write messages to the console before exiting - await Task.Delay(10); - } + return rootCommand; + } } } diff --git a/src/Microsoft.OpenApi.Hidi/readme.md b/src/Microsoft.OpenApi.Hidi/readme.md index 6295c5c99..a6283817e 100644 --- a/src/Microsoft.OpenApi.Hidi/readme.md +++ b/src/Microsoft.OpenApi.Hidi/readme.md @@ -1,24 +1,26 @@ -# Overview +# Overview Hidi is a command line tool that makes it easy to work with and transform OpenAPI documents. The tool enables you validate and apply transformations to and from different file formats using various commands to do different actions on the files. ## Capabilities + Hidi has these key capabilities that enable you to build different scenarios off the tool • Validation of OpenAPI files • Conversion of OpenAPI files into different file formats: convert files from JSON to YAML, YAML to JSON • Slice or filter OpenAPI documents to smaller subsets using operationIDs and tags + • Generate a Mermaid diagram of the API from an OpenAPI document - -## Installation +## Installation Install [Microsoft.OpenApi.Hidi](https://www.nuget.org/packages/Microsoft.OpenApi.Hidi/1.0.0-preview4) package from NuGet by running the following command: -### .NET CLI(Global) +### .NET CLI(Global) + 1. dotnet tool install --global Microsoft.OpenApi.Hidi --prerelease -### .NET CLI(local) +### .NET CLI(local) 1. dotnet new tool-manifest #if you are setting up the OpenAPI.NET repo 2. dotnet tool install --local Microsoft.OpenApi.Hidi --prerelease @@ -27,14 +29,17 @@ Install [Microsoft.OpenApi.Hidi](https://www.nuget.org/packages/Microsoft.OpenAp ## How to use Hidi + Once you've installed the package locally, you can invoke the Hidi by running: hidi [command]. You can access the list of command options we have by running hidi -h The tool avails the following commands: • Validate • Transform + • Show -### Validate +### Validate + This command option accepts an OpenAPI document as an input parameter, visits multiple OpenAPI elements within the document and returns statistics count report on the following elements: • Path Items @@ -54,9 +59,10 @@ It accepts the following command: **Example:** `hidi.exe validate --openapi C:\OpenApidocs\Mail.yml --loglevel trace` -Run validate -h to see the options available. - -### Transform +Run validate -h to see the options available. + +### Transform + Used to convert file formats from JSON to YAML and vice versa and performs slicing of OpenAPI documents. This command accepts the following parameters: @@ -90,3 +96,11 @@ This command accepts the following parameters: hidi transform -cs dataverse.csdl --csdlFilter "appointments,opportunities" -o appointmentsAndOpportunities.yaml -ll trace Run transform -h to see all the available usage options. + +### Show + +This command accepts an OpenAPI document as an input parameter and generates a Markdown file that contains a diagram of the API using Mermaid syntax. + +**Examples:** + + 1. hidi show -d files\People.yml -o People.md -ll trace diff --git a/src/Microsoft.OpenApi/Services/OpenApiUrlTreeNode.cs b/src/Microsoft.OpenApi/Services/OpenApiUrlTreeNode.cs index 30a47bdd7..c8e2da03f 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiUrlTreeNode.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiUrlTreeNode.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; using System.Linq; using Microsoft.OpenApi.Models; @@ -235,5 +237,155 @@ public void AddAdditionalData(Dictionary> additionalData) } } } + + /// + /// Write tree as Mermaid syntax + /// + /// StreamWriter to write the Mermaid content to + public void WriteMermaid(TextWriter writer) + { + writer.WriteLine("graph LR"); + foreach (var style in MermaidNodeStyles) + { + writer.WriteLine($"classDef {style.Key} fill:{style.Value.Color},stroke:#333,stroke-width:2px"); + } + + ProcessNode(this, writer); + } + + /// + /// Dictionary that maps a set of HTTP methods to HTML color. Keys are sorted, uppercased, concatenated HTTP methods. + /// + public readonly static IReadOnlyDictionary MermaidNodeStyles = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "GET", new MermaidNodeStyle("lightSteelBlue", MermaidNodeShape.SquareCornerRectangle) }, + { "POST", new MermaidNodeStyle("Lightcoral", MermaidNodeShape.OddShape) }, + { "GET_POST", new MermaidNodeStyle("forestGreen", MermaidNodeShape.RoundedCornerRectangle) }, + { "DELETE_GET_PATCH", new MermaidNodeStyle("yellowGreen", MermaidNodeShape.Circle) }, + { "DELETE_GET_PATCH_PUT", new MermaidNodeStyle("oliveDrab", MermaidNodeShape.Circle) }, + { "DELETE_GET_PUT", new MermaidNodeStyle("olive", MermaidNodeShape.Circle) }, + { "DELETE_GET", new MermaidNodeStyle("DarkSeaGreen", MermaidNodeShape.Circle) }, + { "DELETE", new MermaidNodeStyle("Tomato", MermaidNodeShape.Rhombus) }, + { "OTHER", new MermaidNodeStyle("White", MermaidNodeShape.SquareCornerRectangle) }, + }; + + private static void ProcessNode(OpenApiUrlTreeNode node, TextWriter writer) + { + var path = string.IsNullOrEmpty(node.Path) ? "/" : SanitizeMermaidNode(node.Path); + var methods = GetMethods(node); + var (startChar, endChar) = GetShapeDelimiters(methods); + foreach (var child in node.Children) + { + var childMethods = GetMethods(child.Value); + var (childStartChar, childEndChar) = GetShapeDelimiters(childMethods); + writer.WriteLine($"{path}{startChar}\"{node.Segment}\"{endChar} --> {SanitizeMermaidNode(child.Value.Path)}{childStartChar}\"{child.Key}\"{childEndChar}"); + ProcessNode(child.Value, writer); + } + if (String.IsNullOrEmpty(methods)) methods = "OTHER"; + writer.WriteLine($"class {path} {methods}"); + } + + private static string GetMethods(OpenApiUrlTreeNode node) + { + return String.Join("_", node.PathItems.SelectMany(p => p.Value.Operations.Select(o => o.Key)) + .Distinct() + .Select(o => o.ToString().ToUpper()) + .OrderBy(o => o) + .ToList()); + } + + private static (string, string) GetShapeDelimiters(string methods) + { + + if (MermaidNodeStyles.TryGetValue(methods, out var style)) + { + //switch on shape + switch (style.Shape) + { + case MermaidNodeShape.Circle: + return ("((", "))"); + case MermaidNodeShape.RoundedCornerRectangle: + return ("(", ")"); + case MermaidNodeShape.Rhombus: + return ("{", "}"); + case MermaidNodeShape.SquareCornerRectangle: + return ("[", "]"); + case MermaidNodeShape.OddShape: + return (">", "]"); + default: + return ("[", "]"); + } + } + else + { + return ("[", "]"); + } + } + private static string SanitizeMermaidNode(string token) + { + return token.Replace("\\", "/") + .Replace("{", ":") + .Replace("}", "") + .Replace(".", "_") + .Replace("(", "_") + .Replace(")", "_") + .Replace(";", "_") + .Replace("-", "_") + .Replace("graph", "gra_ph") // graph is a reserved word + .Replace("default", "def_ault"); // default is a reserved word for classes + } + } + /// + /// Defines the color and shape of a node in a Mermaid graph diagram + /// + public class MermaidNodeStyle + { + /// + /// Create a style that defines the color and shape of a diagram element + /// + /// + /// + internal MermaidNodeStyle(string color, MermaidNodeShape shape) + { + Color = color; + Shape = shape; + } + + /// + /// The CSS color name of the diagram element + /// + public string Color { get; } + + /// + /// The shape of the diagram element + /// + public MermaidNodeShape Shape { get; } + } + + /// + /// Shapes supported by Mermaid diagrams + /// + public enum MermaidNodeShape + { + /// + /// Rectangle with square corners + /// + SquareCornerRectangle, + /// + /// Rectangle with rounded corners + /// + RoundedCornerRectangle, + /// + /// Circle + /// + Circle, + /// + /// Rhombus + /// + Rhombus, + /// + /// Odd shape + /// + OddShape } } diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj b/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj index 578cdc9e3..aaaa66cba 100644 --- a/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj +++ b/test/Microsoft.OpenApi.Hidi.Tests/Microsoft.OpenApi.Hidi.Tests.csproj @@ -53,6 +53,9 @@ Always + + PreserveNewest + Always diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs index a080db11a..ac2048ad1 100644 --- a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs +++ b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiServiceTests.cs @@ -1,10 +1,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text; +using Castle.Core.Logging; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.OpenApi.Hidi; +using Microsoft.OpenApi.Hidi.Handlers; +using Microsoft.OpenApi.Models; using Microsoft.OpenApi.OData; using Microsoft.OpenApi.Services; +using Microsoft.VisualStudio.TestPlatform.Utilities; using Xunit; namespace Microsoft.OpenApi.Tests.Services @@ -71,5 +79,200 @@ public void ReturnOpenApiConvertSettingsWhenSettingsFileIsProvided(string filePa Assert.NotNull(settings); } } + + + [Fact] + public void ShowCommandGeneratesMermaidDiagramAsMarkdown() + { + var openApiDoc = new OpenApiDocument + { + Info = new OpenApiInfo + { + Title = "Test", + Version = "1.0.0" + } + }; + var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + OpenApiService.WriteTreeDocumentAsMarkdown("https://example.org/openapi.json", openApiDoc, writer); + writer.Flush(); + stream.Position = 0; + using var reader = new StreamReader(stream); + var output = reader.ReadToEnd(); + Assert.Contains("graph LR", output); + } + + [Fact] + public void ShowCommandGeneratesMermaidDiagramAsHtml() + { + var openApiDoc = new OpenApiDocument + { + Info = new OpenApiInfo + { + Title = "Test", + Version = "1.0.0" + } + }; + var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + OpenApiService.WriteTreeDocumentAsHtml("https://example.org/openapi.json", openApiDoc, writer); + writer.Flush(); + stream.Position = 0; + using var reader = new StreamReader(stream); + var output = reader.ReadToEnd(); + Assert.Contains("graph LR", output); + } + + + [Fact] + public async Task ShowCommandGeneratesMermaidMarkdownFileWithMermaidDiagram() + { + var fileinfo = new FileInfo("sample.md"); + // create a dummy ILogger instance for testing + await OpenApiService.ShowOpenApiDocument("UtilityFiles\\SampleOpenApi.yml", null, null, fileinfo, new Logger(new LoggerFactory()), new CancellationToken()); + + var output = File.ReadAllText(fileinfo.FullName); + Assert.Contains("graph LR", output); + } + + [Fact] + public async Task ShowCommandGeneratesMermaidHtmlFileWithMermaidDiagram() + { + var filePath = await OpenApiService.ShowOpenApiDocument("UtilityFiles\\SampleOpenApi.yml", null, null, null, new Logger(new LoggerFactory()), new CancellationToken()); + Assert.True(File.Exists(filePath)); + } + + [Fact] + public async Task ShowCommandGeneratesMermaidMarkdownFileFromCsdlWithMermaidDiagram() + { + var fileinfo = new FileInfo("sample.md"); + // create a dummy ILogger instance for testing + await OpenApiService.ShowOpenApiDocument(null, "UtilityFiles\\Todo.xml", "todos", fileinfo, new Logger(new LoggerFactory()), new CancellationToken()); + + var output = File.ReadAllText(fileinfo.FullName); + Assert.Contains("graph LR", output); + } + + [Fact] + public async Task ThrowIfOpenApiUrlIsNotProvidedWhenValidating() + { + await Assert.ThrowsAsync(async () => + await OpenApiService.ValidateOpenApiDocument("", new Logger(new LoggerFactory()), new CancellationToken())); + } + + + [Fact] + public async Task ThrowIfURLIsNotResolvableWhenValidating() + { + await Assert.ThrowsAsync(async () => + await OpenApiService.ValidateOpenApiDocument("https://example.org/itdoesnmatter", new Logger(new LoggerFactory()), new CancellationToken())); + } + + [Fact] + public async Task ThrowIfFileDoesNotExistWhenValidating() + { + await Assert.ThrowsAsync(async () => + await OpenApiService.ValidateOpenApiDocument("aFileThatBetterNotExist.fake", new Logger(new LoggerFactory()), new CancellationToken())); + } + + [Fact] + public async Task ValidateCommandProcessesOpenApi() + { + // create a dummy ILogger instance for testing + await OpenApiService.ValidateOpenApiDocument("UtilityFiles\\SampleOpenApi.yml", new Logger(new LoggerFactory()), new CancellationToken()); + + Assert.True(true); + } + + + [Fact] + public async Task TransformCommandConvertsOpenApi() + { + var fileinfo = new FileInfo("sample.json"); + // create a dummy ILogger instance for testing + await OpenApiService.TransformOpenApiDocument("UtilityFiles\\SampleOpenApi.yml",null, null, fileinfo, true, null, null,false,null,false,false,null,null,null,new Logger(new LoggerFactory()), new CancellationToken()); + + var output = File.ReadAllText("sample.json"); + Assert.NotEmpty(output); + } + + + [Fact] + public async Task TransformCommandConvertsOpenApiWithDefaultOutputname() + { + // create a dummy ILogger instance for testing + await OpenApiService.TransformOpenApiDocument("UtilityFiles\\SampleOpenApi.yml", null, null, null, true, null, null, false, null, false, false, null, null, null, new Logger(new LoggerFactory()), new CancellationToken()); + + var output = File.ReadAllText("output.yml"); + Assert.NotEmpty(output); + } + + [Fact] + public async Task TransformCommandConvertsCsdlWithDefaultOutputname() + { + // create a dummy ILogger instance for testing + await OpenApiService.TransformOpenApiDocument(null, "UtilityFiles\\Todo.xml", null, null, true, null, null, false, null, false, false, null, null, null, new Logger(new LoggerFactory()), new CancellationToken()); + + var output = File.ReadAllText("output.yml"); + Assert.NotEmpty(output); + } + + [Fact] + public async Task TransformCommandConvertsOpenApiWithDefaultOutputnameAndSwitchFormat() + { + // create a dummy ILogger instance for testing + await OpenApiService.TransformOpenApiDocument("UtilityFiles\\SampleOpenApi.yml", null, null, null, true, "3.0", OpenApiFormat.Yaml, false, null, false, false, null, null, null, new Logger(new LoggerFactory()), new CancellationToken()); + + var output = File.ReadAllText("output.yml"); + Assert.NotEmpty(output); + } + + [Fact] + public async Task ThrowTransformCommandIfOpenApiAndCsdlAreEmpty() + { + await Assert.ThrowsAsync(async () => + await OpenApiService.TransformOpenApiDocument(null, null, null, null, true, null, null, false, null, false, false, null, null, null, new Logger(new LoggerFactory()), new CancellationToken())); + + } + + [Fact] + public void InvokeTransformCommand() + { + var rootCommand = Program.CreateRootCommand(); + var args = new string[] { "transform", "-d", ".\\UtilityFiles\\SampleOpenApi.yml", "-o", "sample.json","--co" }; + var parseResult = rootCommand.Parse(args); + var handler = rootCommand.Subcommands.Where(c => c.Name == "transform").First().Handler; + var context = new InvocationContext(parseResult); + + handler.Invoke(context); + + var output = File.ReadAllText("sample.json"); + Assert.NotEmpty(output); + } + + + [Fact] + public void InvokeShowCommand() + { + var rootCommand = Program.CreateRootCommand(); + var args = new string[] { "show", "-d", ".\\UtilityFiles\\SampleOpenApi.yml", "-o", "sample.md" }; + var parseResult = rootCommand.Parse(args); + var handler = rootCommand.Subcommands.Where(c => c.Name == "show").First().Handler; + var context = new InvocationContext(parseResult); + + handler.Invoke(context); + + var output = File.ReadAllText("sample.md"); + Assert.Contains("graph LR", output); + } + + + // Relatively useless test to keep the code coverage metrics happy + [Fact] + public void CreateRootCommand() + { + var rootCommand = Program.CreateRootCommand(); + Assert.NotNull(rootCommand); + } } } diff --git a/test/Microsoft.OpenApi.Hidi.Tests/UtilityFiles/SampleOpenApi.yml b/test/Microsoft.OpenApi.Hidi.Tests/UtilityFiles/SampleOpenApi.yml new file mode 100644 index 000000000..c4fb2e62f --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/UtilityFiles/SampleOpenApi.yml @@ -0,0 +1,19 @@ +openapi: 3.0.0 +info: + title: Sample OpenApi + version: 1.0.0 +paths: + /api/editresource: + get: + responses: + '200': + description: OK + patch: + responses: + '200': + description: OK + /api/viewresource: + get: + responses: + '200': + description: OK \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index db3a3ecf7..63cd0f535 100755 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -1062,6 +1062,19 @@ namespace Microsoft.OpenApi.Services public string Response { get; set; } public string ServerVariable { get; } } + public enum MermaidNodeShape + { + SquareCornerRectangle = 0, + RoundedCornerRectangle = 1, + Circle = 2, + Rhombus = 3, + OddShape = 4, + } + public class MermaidNodeStyle + { + public string Color { get; } + public Microsoft.OpenApi.Services.MermaidNodeShape Shape { get; } + } public static class OpenApiFilterService { public static Microsoft.OpenApi.Models.OpenApiDocument CreateFilteredDocument(Microsoft.OpenApi.Models.OpenApiDocument source, System.Func predicate) { } @@ -1094,6 +1107,7 @@ namespace Microsoft.OpenApi.Services } public class OpenApiUrlTreeNode { + public static readonly System.Collections.Generic.IReadOnlyDictionary MermaidNodeStyles; public System.Collections.Generic.IDictionary> AdditionalData { get; set; } public System.Collections.Generic.IDictionary Children { get; } public bool IsParameter { get; } @@ -1104,6 +1118,7 @@ namespace Microsoft.OpenApi.Services public void Attach(Microsoft.OpenApi.Models.OpenApiDocument doc, string label) { } public Microsoft.OpenApi.Services.OpenApiUrlTreeNode Attach(string path, Microsoft.OpenApi.Models.OpenApiPathItem pathItem, string label) { } public bool HasOperations(string label) { } + public void WriteMermaid(System.IO.TextWriter writer) { } public static Microsoft.OpenApi.Services.OpenApiUrlTreeNode Create() { } public static Microsoft.OpenApi.Services.OpenApiUrlTreeNode Create(Microsoft.OpenApi.Models.OpenApiDocument doc, string label) { } } diff --git a/test/Microsoft.OpenApi.Tests/Services/OpenApiUrlTreeNodeTests.VerifyDiagramFromSampleOpenAPI.verified.txt b/test/Microsoft.OpenApi.Tests/Services/OpenApiUrlTreeNodeTests.VerifyDiagramFromSampleOpenAPI.verified.txt new file mode 100644 index 000000000..c24dd943d --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Services/OpenApiUrlTreeNodeTests.VerifyDiagramFromSampleOpenAPI.verified.txt @@ -0,0 +1,15 @@ +graph LR +classDef GET fill:lightSteelBlue,stroke:#333,stroke-width:2px +classDef POST fill:Lightcoral,stroke:#333,stroke-width:2px +classDef GET_POST fill:forestGreen,stroke:#333,stroke-width:2px +classDef DELETE_GET_PATCH fill:yellowGreen,stroke:#333,stroke-width:2px +classDef DELETE_GET_PATCH_PUT fill:oliveDrab,stroke:#333,stroke-width:2px +classDef DELETE_GET_PUT fill:olive,stroke:#333,stroke-width:2px +classDef DELETE_GET fill:DarkSeaGreen,stroke:#333,stroke-width:2px +classDef DELETE fill:Tomato,stroke:#333,stroke-width:2px +classDef OTHER fill:White,stroke:#333,stroke-width:2px +/["/"] --> /houses("houses") +class /houses GET_POST +/["/"] --> /cars>"cars"] +class /cars POST +class / GET diff --git a/test/Microsoft.OpenApi.Tests/Services/OpenApiUrlTreeNodeTests.cs b/test/Microsoft.OpenApi.Tests/Services/OpenApiUrlTreeNodeTests.cs index 944e6c830..d251c99c1 100644 --- a/test/Microsoft.OpenApi.Tests/Services/OpenApiUrlTreeNodeTests.cs +++ b/test/Microsoft.OpenApi.Tests/Services/OpenApiUrlTreeNodeTests.cs @@ -3,21 +3,43 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Services; +using VerifyXunit; using Xunit; namespace Microsoft.OpenApi.Tests.Services { + [UsesVerify] public class OpenApiUrlTreeNodeTests { private OpenApiDocument OpenApiDocumentSample_1 => new OpenApiDocument() { Paths = new OpenApiPaths() { - ["/"] = new OpenApiPathItem(), - ["/houses"] = new OpenApiPathItem(), + ["/"] = new OpenApiPathItem() { + Operations = new Dictionary() + { + [OperationType.Get] = new OpenApiOperation(), + } + }, + ["/houses"] = new OpenApiPathItem() + { + Operations = new Dictionary() + { + [OperationType.Get] = new OpenApiOperation(), + [OperationType.Post] = new OpenApiOperation() + } + }, ["/cars"] = new OpenApiPathItem() + { + Operations = new Dictionary() + { + [OperationType.Post] = new OpenApiOperation() + } + } } }; @@ -443,5 +465,21 @@ public void ThrowsArgumentNullExceptionForNullArgumentInAddAdditionalDataMethod( Assert.Throws(() => rootNode.AddAdditionalData(null)); } + + [Fact] + public async Task VerifyDiagramFromSampleOpenAPI() + { + var doc1 = OpenApiDocumentSample_1; + + var label1 = "personal"; + var rootNode = OpenApiUrlTreeNode.Create(doc1, label1); + + var writer = new StringWriter(); + rootNode.WriteMermaid(writer); + writer.Flush(); + var diagram = writer.GetStringBuilder().ToString(); + + await Verifier.Verify(diagram); + } } }