From 8e7a01fd65d7566b6eacdc54b7daaaf09ec59927 Mon Sep 17 00:00:00 2001 From: Senn Geerts Date: Wed, 10 Jul 2024 00:13:28 +0200 Subject: [PATCH] #196 Tool rewrite to make its components testable, removed Swachbuckle copy/paste code --- src/AsyncAPI.Saunter.Generator.Cli/Args.cs | 10 - .../AsyncAPI.Saunter.Generator.Cli.csproj | 14 +- .../Commands/Tofile.cs | 42 --- .../Commands/TofileInternal.cs | 195 ----------- .../Internal/DependencyResolver.cs | 24 -- src/AsyncAPI.Saunter.Generator.Cli/Program.cs | 50 +-- .../SwashbuckleImport/CommandRunner.cs | 145 -------- .../SwashbuckleImport/HostFactoryResolver.cs | 322 ------------------ .../SwashbuckleImport/HostingApplication.cs | 118 ------- .../SwashbuckleImport/readme.md | 3 - .../ToFile/AsyncApiDocumentExtractor.cs | 67 ++++ .../ToFile/Environment.cs | 28 ++ .../ToFile/FileWriter.cs | 34 ++ .../ToFile/ServiceExtensions.cs | 15 + .../ToFile/ServiceProviderBuilder.cs | 20 ++ .../ToFile/ToFileCommand.cs | 77 +++++ .../AsyncApiSchema/v2/AsyncApiDocument.cs | 14 +- .../DotnetCliToolTests.cs | 32 +- .../InterfaceAttributeTests.cs | 8 +- 19 files changed, 290 insertions(+), 928 deletions(-) delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/Args.cs delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/Commands/Tofile.cs delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/Internal/DependencyResolver.cs delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostFactoryResolver.cs delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostingApplication.cs delete mode 100644 src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/readme.md create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/ToFile/AsyncApiDocumentExtractor.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/ToFile/Environment.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/ToFile/FileWriter.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceExtensions.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceProviderBuilder.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/ToFile/ToFileCommand.cs diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Args.cs b/src/AsyncAPI.Saunter.Generator.Cli/Args.cs deleted file mode 100644 index 1100a58b..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/Args.cs +++ /dev/null @@ -1,10 +0,0 @@ -// ReSharper disable once CheckNamespace -public static partial class Program -{ - internal const string StartupAssemblyArgument = "startupassembly"; - internal const string DocOption = "--doc"; - internal const string FormatOption = "--format"; - internal const string FileNameOption = "--filename"; - internal const string OutputOption = "--output"; - internal const string EnvOption = "--env"; -} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj b/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj index 0ece5488..e510838e 100644 --- a/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj +++ b/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj @@ -2,7 +2,7 @@ Exe - net6.0;net8.0 + net8.0 enable 12 AsyncAPI.Saunter.Generator.Cli @@ -11,6 +11,7 @@ AsyncAPI Initiative true AsyncAPI.Saunter.Generator.Cli + DotnetTool dotnet-asyncapi asyncapi;aspnetcore;openapi;documentation;amqp;generator;cli;tool readme.md @@ -34,10 +35,13 @@ - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -45,7 +49,7 @@ - + diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Commands/Tofile.cs b/src/AsyncAPI.Saunter.Generator.Cli/Commands/Tofile.cs deleted file mode 100644 index 1c90a9b9..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/Commands/Tofile.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Diagnostics; -using System.Reflection; -using static Program; - -namespace AsyncApi.Saunter.Generator.Cli.Commands; - -internal class Tofile -{ - internal static Func, int> Run(string[] args) => namedArgs => - { - if (!File.Exists(namedArgs[StartupAssemblyArgument])) - { - throw new FileNotFoundException(namedArgs[StartupAssemblyArgument]); - } - - var depsFile = namedArgs[StartupAssemblyArgument].Replace(".dll", ".deps.json"); - var runtimeConfig = namedArgs[StartupAssemblyArgument].Replace(".dll", ".runtimeconfig.json"); - var commandName = args[0]; - - var subProcessArguments = new string[args.Length - 1]; - if (subProcessArguments.Length > 0) - { - Array.Copy(args, 1, subProcessArguments, 0, subProcessArguments.Length); - } - - var assembly = typeof(Program).GetTypeInfo().Assembly; - var subProcessCommandLine = - $"exec --depsfile {EscapePath(depsFile)} " + - $"--runtimeconfig {EscapePath(runtimeConfig)} " + - $"{EscapePath(assembly.Location)} " + - $"_{commandName} {string.Join(" ", subProcessArguments.Select(EscapePath))}"; - - var subProcess = Process.Start("dotnet", subProcessCommandLine); - subProcess.WaitForExit(); - return subProcess.ExitCode; - }; - - private static string EscapePath(string path) - { - return (path.Contains(' ') || string.IsNullOrWhiteSpace(path)) ? "\"" + path + "\"" : path; - } -} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs b/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs deleted file mode 100644 index b833d741..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Reflection; -using System.Runtime.Loader; -using AsyncApi.Saunter.Generator.Cli.SwashbuckleImport; -using LEGO.AsyncAPI; -using LEGO.AsyncAPI.Models; -using LEGO.AsyncAPI.Readers; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using Saunter; -using Saunter.Serialization; -using static Program; -using AsyncApiDocument = Saunter.AsyncApiSchema.v2.AsyncApiDocument; - -namespace AsyncApi.Saunter.Generator.Cli.Commands; - -internal class TofileInternal -{ - private const string defaultDocumentName = null; - - internal static int Run(IDictionary namedArgs) - { - // 1) Configure host with provided startupassembly - var startupAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.Combine(Directory.GetCurrentDirectory(), namedArgs[StartupAssemblyArgument])); - - // 2) Build a service container that's based on the startup assembly - var envVars = (namedArgs.TryGetValue(EnvOption, out var x) && !string.IsNullOrWhiteSpace(x)) ? x.Split(',').Select(x => x.Trim()) : Array.Empty(); - foreach (var envVar in envVars.Select(x => x.Split('=').Select(x => x.Trim()).ToList())) - { - if (envVar.Count is 1 or 2) - { - Environment.SetEnvironmentVariable(envVar[0], envVar.ElementAtOrDefault(1), EnvironmentVariableTarget.Process); - } - else - { - throw new ArgumentOutOfRangeException(EnvOption, namedArgs[EnvOption], "Environment variable should be in the format: env1=value1,env2=value2,env3"); - } - } - var serviceProvider = GetServiceProvider(startupAssembly); - - // 3) Retrieve AsyncAPI via configured provider - var documentProvider = serviceProvider.GetService(); - var asyncapiOptions = serviceProvider.GetService>().Value; - var documentSerializer = serviceProvider.GetRequiredService(); - - var documentNames = (namedArgs.TryGetValue(DocOption, out var doc) && !string.IsNullOrWhiteSpace(doc)) ? [doc] : asyncapiOptions.NamedApis.Keys; - var fileTemplate = (namedArgs.TryGetValue(FileNameOption, out var template) && !string.IsNullOrWhiteSpace(template)) ? template : "{document}_asyncapi.{extension}"; - if (documentNames.Count == 0) - { - if (asyncapiOptions.AssemblyMarkerTypes.Any()) - { - documentNames = [defaultDocumentName]; - } - else - { - throw new ArgumentOutOfRangeException(DocOption, $"No AsyncAPI documents found: {DocOption} = '{doc}'. Known document(s): {string.Join(", ", asyncapiOptions.NamedApis.Keys)}."); - } - } - - foreach (var documentName in documentNames) - { - AsyncApiDocument prototype; - if (documentName == defaultDocumentName) - { - prototype = asyncapiOptions.AsyncApi; - } - else if (!asyncapiOptions.NamedApis.TryGetValue(documentName, out prototype)) - { - throw new ArgumentOutOfRangeException(DocOption, documentName, $"Requested AsyncAPI document not found: '{documentName}'. Known document(s): {string.Join(", ", asyncapiOptions.NamedApis.Keys)}."); - } - - var schema = documentProvider.GetDocument(asyncapiOptions, prototype); - var asyncApiSchemaJson = documentSerializer.Serialize(schema); - var asyncApiDocument = new AsyncApiStringReader().Read(asyncApiSchemaJson, out var diagnostic); - if (diagnostic.Errors.Any()) - { - Console.Error.WriteLine($"AsyncAPI Schema '{documentName ?? "default"}' is not valid ({diagnostic.Errors.Count} Error(s), {diagnostic.Warnings.Count} Warning(s)):" + - $"{Environment.NewLine}{string.Join(Environment.NewLine, diagnostic.Errors.Select(x => $"- {x}"))}"); - } - - // 4) Serialize to specified output location or stdout - var outputPath = (namedArgs.TryGetValue(OutputOption, out var path) && !string.IsNullOrWhiteSpace(path)) ? Path.Combine(Directory.GetCurrentDirectory(), path) : null; - if (!string.IsNullOrEmpty(outputPath)) - { - Directory.CreateDirectory(outputPath); - } - - var exportJson = true; - var exportYml = false; - var exportYaml = false; - if (namedArgs.TryGetValue(FormatOption, out var format) && !string.IsNullOrWhiteSpace(format)) - { - var splitted = format.Split(',').Select(x => x.Trim()).ToList(); - exportJson = splitted.Any(x => x.Equals("json", StringComparison.OrdinalIgnoreCase)); - exportYml = splitted.Any(x => x.Equals("yml", StringComparison.OrdinalIgnoreCase)); - exportYaml = splitted.Any(x => x.Equals("yaml", StringComparison.OrdinalIgnoreCase)); - } - - if (exportJson) - { - WriteFile(AddFileExtension(outputPath, fileTemplate, documentName, "json"), stream => asyncApiDocument.SerializeAsJson(stream, AsyncApiVersion.AsyncApi2_0)); - } - - if (exportYml) - { - WriteFile(AddFileExtension(outputPath, fileTemplate, documentName, "yml"), stream => asyncApiDocument.SerializeAsYaml(stream, AsyncApiVersion.AsyncApi2_0)); - } - - if (exportYaml) - { - WriteFile(AddFileExtension(outputPath, fileTemplate, documentName, "yaml"), stream => asyncApiDocument.SerializeAsYaml(stream, AsyncApiVersion.AsyncApi2_0)); - } - } - - return 0; - } - - private static void WriteFile(string outputPath, Action writeAction) - { - using var stream = outputPath != null ? File.Create(outputPath) : Console.OpenStandardOutput(); - writeAction(stream); - - if (outputPath != null) - { - Console.WriteLine($"AsyncAPI {Path.GetExtension(outputPath)[1..]} successfully written to {outputPath}"); - } - } - - private static string AddFileExtension(string outputPath, string fileTemplate, string documentName, string extension) - { - if (outputPath == null) - { - return outputPath; - } - - return Path.Combine(outputPath, fileTemplate.Replace("{document}", documentName == defaultDocumentName ? "" : documentName).Replace("{extension}", extension).TrimStart('_')); - } - - private static IServiceProvider GetServiceProvider(Assembly startupAssembly) - { - if (TryGetCustomHost(startupAssembly, "AsyncAPIHostFactory", "CreateHost", out IHost host)) - { - return host.Services; - } - - if (TryGetCustomHost(startupAssembly, "AsyncAPIWebHostFactory", "CreateWebHost", out IWebHost webHost)) - { - return webHost.Services; - } - - try - { - return WebHost.CreateDefaultBuilder().UseStartup(startupAssembly.GetName().Name).Build().Services; - } - catch - { - var serviceProvider = HostingApplication.GetServiceProvider(startupAssembly); - - if (serviceProvider != null) - { - return serviceProvider; - } - - throw; - } - } - - private static bool TryGetCustomHost(Assembly startupAssembly, string factoryClassName, string factoryMethodName, out THost host) - { - // Scan the assembly for any types that match the provided naming convention - var factoryTypes = startupAssembly.DefinedTypes.Where(t => t.Name == factoryClassName).ToList(); - - if (factoryTypes.Count == 0) - { - host = default; - return false; - } - else if (factoryTypes.Count > 1) - { - throw new InvalidOperationException($"Multiple {factoryClassName} classes detected"); - } - - var factoryMethod = factoryTypes.Single().GetMethod(factoryMethodName, BindingFlags.Public | BindingFlags.Static); - - if (factoryMethod == null || factoryMethod.ReturnType != typeof(THost)) - { - throw new InvalidOperationException($"{factoryClassName} class detected but does not contain a public static method called {factoryMethodName} with return type {typeof(THost).Name}"); - } - - host = (THost)factoryMethod.Invoke(null, null); - return true; - } -} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Internal/DependencyResolver.cs b/src/AsyncAPI.Saunter.Generator.Cli/Internal/DependencyResolver.cs deleted file mode 100644 index d136cf1f..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/Internal/DependencyResolver.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; - -namespace AsyncAPI.Saunter.Generator.Cli.Internal; - -internal static class DependencyResolver -{ - public static void Init() - { - var basePath = Path.GetDirectoryName(typeof(Program).GetTypeInfo().Assembly.Location); - AppDomain.CurrentDomain.AssemblyResolve += (sender, args) => - { - var requestedAssembly = new AssemblyName(args.Name); - var fullPath = Path.Combine(basePath, $"{requestedAssembly.Name}.dll"); - if (File.Exists(fullPath)) - { - var assembly = Assembly.LoadFile(fullPath); - return assembly; - } - - Console.WriteLine($"Could not resolve assembly: {args.Name}, requested by {args.RequestingAssembly?.FullName}"); - return default; - }; - } -} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Program.cs b/src/AsyncAPI.Saunter.Generator.Cli/Program.cs index b9facd2b..3502ea7d 100644 --- a/src/AsyncAPI.Saunter.Generator.Cli/Program.cs +++ b/src/AsyncAPI.Saunter.Generator.Cli/Program.cs @@ -1,39 +1,17 @@ -using AsyncApi.Saunter.Generator.Cli.Commands; -using AsyncApi.Saunter.Generator.Cli.SwashbuckleImport; -using AsyncAPI.Saunter.Generator.Cli.Internal; +using AsyncAPI.Saunter.Generator.Cli.ToFile; +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; -DependencyResolver.Init(); +var services = new ServiceCollection(); +services.AddLogging(builder => builder.AddSimpleConsole(x => x.SingleLine = true).SetMinimumLevel(LogLevel.Trace)); +services.AddToFileCommand(); -// Helper to simplify command line parsing etc. -var runner = new CommandRunner("dotnet asyncapi", "AsyncAPI Command Line Tools", Console.Out); +using var serviceProvider = services.BuildServiceProvider(); +var logger = serviceProvider.GetRequiredService>(); +ConsoleApp.LogError = msg => logger.LogError(msg); +ConsoleApp.ServiceProvider = serviceProvider; -// NOTE: The "dotnet asyncapi tofile" command does not serve the request directly. Instead, it invokes a corresponding -// command (called _tofile) via "dotnet exec" so that the runtime configuration (*.runtimeconfig & *.deps.json) of the -// provided startupassembly can be used instead of the tool's. This is neccessary to successfully load the -// startupassembly and it's transitive dependencies. See https://github.com/dotnet/coreclr/issues/13277 for more. - -// > dotnet asyncapi tofile ... -runner.SubCommand("tofile", "retrieves AsyncAPI from a startup assembly, and writes to file ", c => -{ - c.Argument(StartupAssemblyArgument, "relative path to the application's startup assembly"); - c.Option(DocOption, "name(s) of the AsyncAPI documents you want to retrieve, as configured in your startup class [defaults to all documents]"); - c.Option(OutputOption, "relative path where the AsyncAPI will be output [defaults to stdout]"); - c.Option(FileNameOption, "defines the file name template, {document} and {extension} template variables can be used [defaults to \"{document}_asyncapi.{extension}\"]"); - c.Option(FormatOption, "exports AsyncAPI in json and/or yml format [defaults to json]"); - c.Option(EnvOption, "define environment variable(s) for the application during generation of the AsyncAPI files [defaults to empty, can be used to define for example ASPNETCORE_ENVIRONMENT]"); - c.OnRun(Tofile.Run(args)); -}); - -// > dotnet asyncapi _tofile ... (* should only be invoked via "dotnet exec") -runner.SubCommand("_tofile", "", c => -{ - c.Argument(StartupAssemblyArgument, ""); - c.Option(DocOption, ""); - c.Option(OutputOption, ""); - c.Option(FileNameOption, ""); - c.Option(FormatOption, ""); - c.Option(EnvOption, ""); - c.OnRun(TofileInternal.Run); -}); - -return runner.Run(args); +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); diff --git a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs b/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs deleted file mode 100644 index c3c8eca0..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs +++ /dev/null @@ -1,145 +0,0 @@ -namespace AsyncApi.Saunter.Generator.Cli.SwashbuckleImport; - -internal class CommandRunner -{ - private readonly Dictionary _argumentDescriptors; - private readonly Dictionary _optionDescriptors; - private Func, int> _runFunc; - private readonly List _subRunners; - private readonly TextWriter _output; - - public CommandRunner(string commandName, string commandDescription, TextWriter output) - { - CommandName = commandName; - CommandDescription = commandDescription; - _argumentDescriptors = []; - _optionDescriptors = []; - _runFunc = (_) => 1; // no-op - _subRunners = []; - _output = output; - } - - public string CommandName { get; private set; } - - public string CommandDescription { get; private set; } - - public void Argument(string name, string description) - { - _argumentDescriptors.Add(name, description); - } - - public void Option(string name, string description, bool isFlag = false) - { - if (!name.StartsWith("--")) throw new ArgumentException("name of option must begin with --"); - _optionDescriptors.Add(name, new OptionDescriptor { Description = description, IsFlag = isFlag }); - } - - public void OnRun(Func, int> runFunc) - { - _runFunc = runFunc; - } - - public void SubCommand(string name, string description, Action configAction) - { - var runner = new CommandRunner($"{CommandName} {name}", description, _output); - configAction(runner); - _subRunners.Add(runner); - } - - public int Run(IEnumerable args) - { - if (args.Any()) - { - var subRunner = _subRunners.FirstOrDefault(r => r.CommandName.Split(' ').Last() == args.First()); - if (subRunner != null) return subRunner.Run(args.Skip(1)); - } - - if (_subRunners.Any() || !TryParseArgs(args, out IDictionary namedArgs)) - { - PrintUsage(); - return 1; - } - - return _runFunc(namedArgs); - } - - private bool TryParseArgs(IEnumerable args, out IDictionary namedArgs) - { - namedArgs = new Dictionary(); - var argsQueue = new Queue(args); - - // Process options first - while (argsQueue.Any() && argsQueue.Peek().StartsWith("--")) - { - // Ensure it's a known option - var name = argsQueue.Dequeue(); - if (!_optionDescriptors.TryGetValue(name, out OptionDescriptor optionDescriptor)) - return false; - - // If it's not a flag, ensure it's followed by a corresponding value - if (!optionDescriptor.IsFlag && (!argsQueue.Any() || argsQueue.Peek().StartsWith("--"))) - return false; - - namedArgs.Add(name, (!optionDescriptor.IsFlag ? argsQueue.Dequeue() : null)); - } - - // Process required args - ensure corresponding values are provided - foreach (var name in _argumentDescriptors.Keys) - { - if (!argsQueue.Any() || argsQueue.Peek().StartsWith("--")) return false; - namedArgs.Add(name, argsQueue.Dequeue()); - } - - return argsQueue.Count() == 0; - } - - private void PrintUsage() - { - if (_subRunners.Any()) - { - // List sub commands - _output.WriteLine(CommandDescription); - _output.WriteLine("Commands:"); - foreach (var runner in _subRunners) - { - var shortName = runner.CommandName.Split(' ').Last(); - if (shortName.StartsWith("_")) continue; // convention to hide commands - _output.WriteLine($" {shortName}: {runner.CommandDescription}"); - } - _output.WriteLine(); - } - else - { - // Usage for this command - var optionsPart = _optionDescriptors.Any() ? "[options] " : ""; - var argParts = _argumentDescriptors.Keys.Select(name => $"[{name}]"); - _output.WriteLine($"Usage: {CommandName} {optionsPart}{string.Join(" ", argParts)}"); - _output.WriteLine(); - - // Arguments - foreach (var entry in _argumentDescriptors) - { - _output.WriteLine($"{entry.Key}:"); - _output.WriteLine($" {entry.Value}"); - _output.WriteLine(); - } - - // Options - if (_optionDescriptors.Any()) - { - _output.WriteLine("options:"); - foreach (var entry in _optionDescriptors) - { - _output.WriteLine($" {entry.Key}: {entry.Value.Description}"); - } - _output.WriteLine(); - } - } - } - - private struct OptionDescriptor - { - public string Description; - public bool IsFlag; - } -} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostFactoryResolver.cs b/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostFactoryResolver.cs deleted file mode 100644 index 266e47f4..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostFactoryResolver.cs +++ /dev/null @@ -1,322 +0,0 @@ -using System.Diagnostics; -using System.Reflection; - -namespace Microsoft.Extensions.Hosting; - -internal sealed class HostFactoryResolver -{ - private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; - - public const string BuildWebHost = nameof(BuildWebHost); - public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder); - public const string CreateHostBuilder = nameof(CreateHostBuilder); - - // The amount of time we wait for the diagnostic source events to fire - private static readonly TimeSpan s_defaultWaitTimeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(30); - - public static Func ResolveWebHostFactory(Assembly assembly) - { - return ResolveFactory(assembly, BuildWebHost); - } - - public static Func ResolveWebHostBuilderFactory(Assembly assembly) - { - return ResolveFactory(assembly, CreateWebHostBuilder); - } - - public static Func ResolveHostBuilderFactory(Assembly assembly) - { - return ResolveFactory(assembly, CreateHostBuilder); - } - - // This helpers encapsulates all of the complex logic required to: - // 1. Execute the entry point of the specified assembly in a different thread. - // 2. Wait for the diagnostic source events to fire - // 3. Give the caller a chance to execute logic to mutate the IHostBuilder - // 4. Resolve the instance of the applications's IHost - // 5. Allow the caller to determine if the entry point has completed - public static Func ResolveHostFactory(Assembly assembly, - TimeSpan? waitTimeout = null, - bool stopApplication = true, - Action configureHostBuilder = null, - Action entrypointCompleted = null) - { - if (assembly.EntryPoint is null) - { - return null; - } - - try - { - // Attempt to load hosting and check the version to make sure the events - // even have a chance of firing (they were added in .NET >= 6) - var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting"); - if (hostingAssembly.GetName().Version is Version version && version.Major < 6) - { - return null; - } - - // We're using a version >= 6 so the events can fire. If they don't fire - // then it's because the application isn't using the hosting APIs - } - catch - { - // There was an error loading the extensions assembly, return null. - return null; - } - - return args => new HostingListener(args, assembly.EntryPoint, waitTimeout ?? s_defaultWaitTimeout, stopApplication, configureHostBuilder, entrypointCompleted).CreateHost(); - } - - private static Func ResolveFactory(Assembly assembly, string name) - { - var programType = assembly?.EntryPoint?.DeclaringType; - if (programType == null) - { - return null; - } - - var factory = programType.GetMethod(name, DeclaredOnlyLookup); - if (!IsFactory(factory)) - { - return null; - } - - return args => (T)factory.Invoke(null, [args]); - } - - // TReturn Factory(string[] args); - private static bool IsFactory(MethodInfo factory) - { - return factory != null - && typeof(TReturn).IsAssignableFrom(factory.ReturnType) - && factory.GetParameters().Length == 1 - && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); - } - - // Used by EF tooling without any Hosting references. Looses some return type safety checks. - public static Func ResolveServiceProviderFactory(Assembly assembly, TimeSpan? waitTimeout = null) - { - // Prefer the older patterns by default for back compat. - var webHostFactory = ResolveWebHostFactory(assembly); - if (webHostFactory != null) - { - return args => - { - var webHost = webHostFactory(args); - return GetServiceProvider(webHost); - }; - } - - var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); - if (webHostBuilderFactory != null) - { - return args => - { - var webHostBuilder = webHostBuilderFactory(args); - var webHost = Build(webHostBuilder); - return GetServiceProvider(webHost); - }; - } - - var hostBuilderFactory = ResolveHostBuilderFactory(assembly); - if (hostBuilderFactory != null) - { - return args => - { - var hostBuilder = hostBuilderFactory(args); - var host = Build(hostBuilder); - return GetServiceProvider(host); - }; - } - - var hostFactory = ResolveHostFactory(assembly, waitTimeout: waitTimeout); - if (hostFactory != null) - { - return args => - { - var host = hostFactory(args); - return GetServiceProvider(host); - }; - } - - return null; - } - - private static object Build(object builder) - { - var buildMethod = builder.GetType().GetMethod("Build"); - return buildMethod?.Invoke(builder, []); - } - - private static IServiceProvider GetServiceProvider(object host) - { - if (host == null) - { - return null; - } - var hostType = host.GetType(); - var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup); - return (IServiceProvider)servicesProperty?.GetValue(host); - } - - private sealed class HostingListener : IObserver, IObserver> - { - private readonly string[] _args; - private readonly MethodInfo _entryPoint; - private readonly TimeSpan _waitTimeout; - private readonly bool _stopApplication; - - private readonly TaskCompletionSource _hostTcs = new(); - private IDisposable _disposable; - private readonly Action _configure; - private readonly Action _entrypointCompleted; - private static readonly AsyncLocal _currentListener = new(); - - public HostingListener( - string[] args, - MethodInfo entryPoint, - TimeSpan waitTimeout, - bool stopApplication, - Action configure, - Action entrypointCompleted) - { - _args = args; - _entryPoint = entryPoint; - _waitTimeout = waitTimeout; - _stopApplication = stopApplication; - _configure = configure; - _entrypointCompleted = entrypointCompleted; - } - - public object CreateHost() - { - using var subscription = DiagnosticListener.AllListeners.Subscribe(this); - - // Kick off the entry point on a new thread so we don't block the current one - // in case we need to timeout the execution - var thread = new Thread(() => - { - Exception exception = null; - - try - { - // Set the async local to the instance of the HostingListener so we can filter events that - // aren't scoped to this execution of the entry point. - _currentListener.Value = this; - - var parameters = _entryPoint.GetParameters(); - if (parameters.Length == 0) - { - _entryPoint.Invoke(null, []); - } - else - { - _entryPoint.Invoke(null, [_args]); - } - - // Try to set an exception if the entry point returns gracefully, this will force - // build to throw - _hostTcs.TrySetException(new InvalidOperationException("Unable to build IHost")); - } - catch (TargetInvocationException tie) when (tie.InnerException is StopTheHostException) - { - // The host was stopped by our own logic - } - catch (TargetInvocationException tie) - { - exception = tie.InnerException ?? tie; - - // Another exception happened, propagate that to the caller - _hostTcs.TrySetException(exception); - } - catch (Exception ex) - { - exception = ex; - - // Another exception happened, propagate that to the caller - _hostTcs.TrySetException(ex); - } - finally - { - // Signal that the entry point is completed - _entrypointCompleted?.Invoke(exception); - } - }) - { - // Make sure this doesn't hang the process - IsBackground = true - }; - - // Start the thread - thread.Start(); - - try - { - // Wait before throwing an exception - if (!_hostTcs.Task.Wait(_waitTimeout)) - { - throw new InvalidOperationException("Unable to build IHost"); - } - } - catch (AggregateException) when (_hostTcs.Task.IsCompleted) - { - // Lets this propagate out of the call to GetAwaiter().GetResult() - } - - Debug.Assert(_hostTcs.Task.IsCompleted); - - return _hostTcs.Task.GetAwaiter().GetResult(); - } - - public void OnCompleted() - { - _disposable?.Dispose(); - } - - public void OnError(Exception error) - { - } - - public void OnNext(DiagnosticListener value) - { - if (_currentListener.Value != this) - { - // Ignore events that aren't for this listener - return; - } - - if (value.Name == "Microsoft.Extensions.Hosting") - { - _disposable = value.Subscribe(this); - } - } - - public void OnNext(KeyValuePair value) - { - if (_currentListener.Value != this) - { - // Ignore events that aren't for this listener - return; - } - - if (value.Key == "HostBuilding") - { - _configure?.Invoke(value.Value); - } - - if (value.Key == "HostBuilt") - { - _hostTcs.TrySetResult(value.Value); - - if (_stopApplication) - { - // Stop the host from running further - throw new StopTheHostException(); - } - } - } - - private sealed class StopTheHostException : Exception; - } -} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostingApplication.cs b/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostingApplication.cs deleted file mode 100644 index e4635aff..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/HostingApplication.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Reflection; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Http.Features; -#if NETCOREAPP3_0_OR_GREATER -using Microsoft.Extensions.DependencyInjection; -#endif -using Microsoft.Extensions.Hosting; - -namespace AsyncApi.Saunter.Generator.Cli.SwashbuckleImport; - -// Represents an application that uses Microsoft.Extensions.Hosting and supports -// the various entry point flavors. The final model *does not* have an explicit CreateHost entry point and thus inverts the typical flow where the -// execute Main and we wait for events to fire in order to access the appropriate state. -// This is what allows top level statements to work, but getting the IServiceProvider is slightly more complex. -internal class HostingApplication -{ - internal static IServiceProvider GetServiceProvider(Assembly assembly) - { -#if NETCOREAPP2_1 - return null; -#else - // We're disabling the default server and the console host lifetime. This will disable: - // 1. Listening on ports - // 2. Logging to the console from the default host. - // This is essentially what the test server does in order to get access to the application's - // IServicerProvider *and* middleware pipeline. - void ConfigureHostBuilder(object hostBuilder) - { - ((IHostBuilder)hostBuilder).ConfigureServices((context, services) => - { - services.AddSingleton(); - services.AddSingleton(); - - for (var i = services.Count - 1; i >= 0; i--) - { - // exclude all implementations of IHostedService - // except Microsoft.AspNetCore.Hosting.GenericWebHostService because that one will build/configure - // the WebApplication/Middleware pipeline in the case of the GenericWebHostBuilder. - var registration = services[i]; - if (registration.ServiceType == typeof(IHostedService) - && registration.ImplementationType is not { FullName: "Microsoft.AspNetCore.Hosting.GenericWebHostService" }) - { - services.RemoveAt(i); - } - } - }); - } - - var waitForStartTcs = new TaskCompletionSource(); - - void OnEntryPointExit(Exception exception) - { - // If the entry point exited, we'll try to complete the wait - if (exception != null) - { - waitForStartTcs.TrySetException(exception); - } - else - { - waitForStartTcs.TrySetResult(null); - } - } - - // If all of the existing techniques fail, then try to resolve the ResolveHostFactory - var factory = HostFactoryResolver.ResolveHostFactory(assembly, - stopApplication: false, - configureHostBuilder: ConfigureHostBuilder, - entrypointCompleted: OnEntryPointExit); - - // We're unable to resolve the factory. This could mean the application wasn't referencing the right - // version of hosting. - if (factory == null) - { - return null; - } - - try - { - // Get the IServiceProvider from the host - var assemblyName = assembly.GetName()?.FullName ?? string.Empty; - // We set the application name in the hosting environment to the startup assembly - // to avoid falling back to the entry assembly (dotnet-swagger) when configuring our - // application. - var services = ((IHost)factory([$"--{HostDefaults.ApplicationKey}={assemblyName}"])).Services; - - // Wait for the application to start so that we know it's fully configured. This is important because - // we need the middleware pipeline to be configured before we access the ISwaggerProvider in - // in the IServiceProvider - var applicationLifetime = services.GetRequiredService(); - - using var registration = applicationLifetime.ApplicationStarted.Register(() => waitForStartTcs.TrySetResult(null)); - waitForStartTcs.Task.Wait(); - - return services; - } - catch (InvalidOperationException) - { - // We're unable to resolve the host, swallow the exception and return null - } - - return null; -#endif - } - - private class NoopHostLifetime : IHostLifetime - { - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } - - private class NoopServer : IServer - { - public IFeatureCollection Features { get; } = new FeatureCollection(); - public void Dispose() { } - public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } -} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/readme.md b/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/readme.md deleted file mode 100644 index babea97b..00000000 --- a/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -This code is taken from [Swashbuckle.AspNetCore.Cli](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/tree/master/src/Swashbuckle.AspNetCore.Cli) - -Since Swashbuckle.AspNetCore.Cli is delivered as a tool, code cannot be reference through Nuget. \ No newline at end of file diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/AsyncApiDocumentExtractor.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/AsyncApiDocumentExtractor.cs new file mode 100644 index 00000000..9f070ddf --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/AsyncApiDocumentExtractor.cs @@ -0,0 +1,67 @@ +using LEGO.AsyncAPI.Models; +using LEGO.AsyncAPI.Readers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Saunter.Serialization; +using Saunter; +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal class AsyncApiDocumentExtractor(ILogger logger) +{ + private IEnumerable GetDocumentNames(string[] requestedDocuments, AsyncApiOptions asyncApiOptions) + { + var documentNames = requestedDocuments ?? asyncApiOptions.NamedApis.Keys; + if (documentNames.Count == 0) + { + if (asyncApiOptions.AssemblyMarkerTypes.Any()) + { + documentNames = [null]; // null marks the default, unnamed, document + } + else + { + logger.LogCritical($"AsyncAPI documents found. Known named document(s): {string.Join(", ", asyncApiOptions.NamedApis.Keys)}."); + } + } + return documentNames; + } + + public IEnumerable<(string name, AsyncApiDocument document)> GetAsyncApiDocument(IServiceProvider serviceProvider, string[] requestedDocuments) + { + var documentProvider = serviceProvider.GetService(); + var asyncApiOptions = serviceProvider.GetService>().Value; + var documentSerializer = serviceProvider.GetRequiredService(); + + foreach (var documentName in GetDocumentNames(requestedDocuments, asyncApiOptions)) + { + if (documentName == null || !asyncApiOptions.NamedApis.TryGetValue(documentName, out var prototype)) + { + prototype = asyncApiOptions.AsyncApi; + } + + var schema = documentProvider.GetDocument(asyncApiOptions, prototype); + var asyncApiSchemaJson = documentSerializer.Serialize(schema); + var asyncApiDocument = new AsyncApiStringReader().Read(asyncApiSchemaJson, out var diagnostic); + if (diagnostic.Errors.Any()) + { + logger.LogError($"AsyncAPI Schema '{documentName ?? "default"}' is not valid ({diagnostic.Errors.Count} Error(s))"); + foreach (var error in diagnostic.Errors) + { + logger.LogError($"- {error}"); + } + } + if (diagnostic.Warnings.Any()) + { + logger.LogWarning($"AsyncAPI Schema '{documentName ?? "default"}' has {diagnostic.Warnings.Count} Warning(s):"); + foreach (var warning in diagnostic.Warnings) + { + logger.LogWarning($"- {warning}"); + } + } + + yield return (documentName, asyncApiDocument); + } + } +} + diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/Environment.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/Environment.cs new file mode 100644 index 00000000..040637a4 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/Environment.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal class EnvironmentBuilder(ILogger logger) +{ + public void SetEnvironmentVariables(string env) + { + var envVars = !string.IsNullOrWhiteSpace(env) ? env.Split(',').Select(x => x.Trim()) : Array.Empty(); + foreach (var envVar in envVars.Select(x => x.Split('=').Select(x => x.Trim()).ToList())) + { + if (envVar.Count is 1) + { + Environment.SetEnvironmentVariable(envVar[0], null, EnvironmentVariableTarget.Process); + logger.LogDebug($"Set environment flag: {envVar[0]}"); + } + if (envVar.Count is 2) + { + Environment.SetEnvironmentVariable(envVar[0], envVar.ElementAtOrDefault(1), EnvironmentVariableTarget.Process); + logger.LogDebug($"Set environment variable: {envVar[0]} = {envVar[1]}"); + } + else + { + logger.LogCritical("Environment variables should be in the format: env1=value1,env2=value2,env3"); + } + } + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/FileWriter.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/FileWriter.cs new file mode 100644 index 00000000..62e1bc43 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/FileWriter.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal class FileWriter(ILogger logger) +{ + public void Write(string outputPath, string fileTemplate, string documentName, string format, Action streamWriter) + { + var fullFileName = AddFileExtension(outputPath, fileTemplate, documentName, format); + this.WriteFile(fullFileName, streamWriter); + } + + private void WriteFile(string outputPath, Action writeAction) + { + using var stream = outputPath != null ? File.Create(outputPath) : Console.OpenStandardOutput(); + writeAction(stream); + + if (outputPath != null) + { + logger.LogInformation($"AsyncAPI {Path.GetExtension(outputPath)[1..]} successfully written to {outputPath}"); + } + } + + private static string AddFileExtension(string outputPath, string fileTemplate, string documentName, string extension) + { + if (outputPath == null) + { + return outputPath; + } + + return Path.GetFullPath(Path.Combine(outputPath, fileTemplate.Replace("{document}", documentName ?? "") + .Replace("{extension}", extension).TrimStart('_'))); + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceExtensions.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceExtensions.cs new file mode 100644 index 00000000..041e496e --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal static class ServiceExtensions +{ + public static IServiceCollection AddToFileCommand(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + return services; + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceProviderBuilder.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceProviderBuilder.cs new file mode 100644 index 00000000..6a2907a5 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceProviderBuilder.cs @@ -0,0 +1,20 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using System.Runtime.Loader; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal class ServiceProviderBuilder(ILogger logger) +{ + public IServiceProvider BuildServiceProvider(string startupAssembly) + { + var fullPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), startupAssembly)); + logger.LogInformation($"Loading startup assembly: {fullPath}"); + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); + var nswagCommandsAssembly = Assembly.LoadFrom("NSwag.Commands.dll"); + var nswagServiceProvider = nswagCommandsAssembly.GetType("NSwag.Commands.ServiceProviderResolver"); + var serviceProvider = (IServiceProvider)nswagServiceProvider.InvokeMember("GetServiceProvider", BindingFlags.InvokeMethod | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, null, [assembly]); + return serviceProvider; + } +} + diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ToFileCommand.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ToFileCommand.cs new file mode 100644 index 00000000..5a5172b9 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ToFileCommand.cs @@ -0,0 +1,77 @@ +using ConsoleAppFramework; +using LEGO.AsyncAPI; +using LEGO.AsyncAPI.Models; +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal class ToFileCommand(ILogger logger, EnvironmentBuilder environment, ServiceProviderBuilder builder, AsyncApiDocumentExtractor docExtractor, FileWriter fileWriter) +{ + private const string DEFAULT_FILENAME = "{document}_asyncapi.{extension}"; + + /// + /// Retrieves AsyncAPI spec from a startup assembly and writes to file. + /// + /// relative path to the application's startup assembly + /// -o,relative path where the AsyncAPI will be output [defaults to stdout] + /// -d,name(s) of the AsyncAPI documents you want to retrieve, as configured in your startup class [defaults to all documents] + /// exports AsyncAPI in json and/or yml format [defaults to json] + /// defines the file name template, {document} and {extension} template variables can be used [defaults to "{document}_asyncapi.{extension}\"] + /// define environment variable(s) for the application. Formatted as a comma separated list of key=value pairs or just key for flags + [Command("tofile")] + public int ToFile([Argument] string startupassembly, string output = "./", string doc = null, string format = "json", string filename = DEFAULT_FILENAME, string env = "") + { + if (!File.Exists(startupassembly)) + { + throw new FileNotFoundException(startupassembly); + } + + // Prepare environment + environment.SetEnvironmentVariables(env); + + // Get service provider from startup assembly + var serviceProvider = builder.BuildServiceProvider(startupassembly); + + // Retrieve AsyncAPI via service provider + var documents = docExtractor.GetAsyncApiDocument(serviceProvider, !string.IsNullOrWhiteSpace(doc) ? doc.Split(',', StringSplitOptions.RemoveEmptyEntries) : null); + foreach (var (documentName, asyncApiDocument) in documents) + { + // Serialize to specified output location or stdout + var outputPath = !string.IsNullOrWhiteSpace(output) ? Path.Combine(Directory.GetCurrentDirectory(), output) : null; + if (!string.IsNullOrEmpty(outputPath)) + { + Directory.CreateDirectory(outputPath); + } + + var exportJson = true; + var exportYml = false; + var exportYaml = false; + if (!string.IsNullOrWhiteSpace(format)) + { + var splitted = format.Split(',').Select(x => x.Trim()).ToList(); + exportJson = splitted.Any(x => x.Equals("json", StringComparison.OrdinalIgnoreCase)); + exportYml = splitted.Any(x => x.Equals("yml", StringComparison.OrdinalIgnoreCase)); + exportYaml = splitted.Any(x => x.Equals("yaml", StringComparison.OrdinalIgnoreCase)); + } + logger.LogDebug($"Format: exportJson={exportJson}, exportYml={exportYml}, exportYaml={exportYaml}."); + + var fileTemplate = !string.IsNullOrWhiteSpace(filename) ? filename : DEFAULT_FILENAME; + if (exportJson) + { + fileWriter.Write(outputPath, fileTemplate, documentName, "json", stream => asyncApiDocument.SerializeAsJson(stream, AsyncApiVersion.AsyncApi2_0)); + } + + if (exportYml) + { + fileWriter.Write(outputPath, fileTemplate, documentName, "yml", stream => asyncApiDocument.SerializeAsYaml(stream, AsyncApiVersion.AsyncApi2_0)); + } + + if (exportYaml) + { + fileWriter.Write(outputPath, fileTemplate, documentName, "yaml", stream => asyncApiDocument.SerializeAsYaml(stream, AsyncApiVersion.AsyncApi2_0)); + } + } + + return 1; + } +} diff --git a/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs b/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs index ed38829a..a5604b64 100644 --- a/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs +++ b/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using System.Linq; - -using Newtonsoft.Json; - +using System.Linq; + +using Newtonsoft.Json; + using NJsonSchema.NewtonsoftJson.Converters; namespace Saunter.AsyncApiSchema.v2 @@ -15,8 +15,8 @@ public class AsyncApiDocument : ICloneable /// Specifies the AsyncAPI Specification version being used. /// [JsonProperty("asyncapi", NullValueHandling = NullValueHandling.Ignore)] - public string AsyncApi { get; } = "2.4.0"; - + public string AsyncApi { get; } = "2.4.0"; + /// /// Identifier of the application the AsyncAPI document is defining. /// @@ -105,4 +105,4 @@ object ICloneable.Clone() return Clone(); } } -} +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs index 47746fc8..8d9e12d3 100644 --- a/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs @@ -6,11 +6,11 @@ namespace AsyncAPI.Saunter.Generator.Cli.Tests; public class DotnetCliToolTests(ITestOutputHelper output) { - private string RunTool(string args, int expectedExitCode = 0) + private string RunTool(string args, int expectedExitCode = 1) { var process = Process.Start(new ProcessStartInfo("dotnet") { - Arguments = $"../../../../../src/AsyncAPI.Saunter.Generator.Cli/bin/Debug/net6.0/AsyncAPI.Saunter.Generator.Cli.dll tofile {args}", + Arguments = $"../../../../../src/AsyncAPI.Saunter.Generator.Cli/bin/Debug/net8.0/AsyncAPI.Saunter.Generator.Cli.dll tofile {args}", RedirectStandardOutput = true, RedirectStandardError = true, }); @@ -28,20 +28,22 @@ private string RunTool(string args, int expectedExitCode = 0) [Fact] public void DefaultCallPrintsCommandInfo() { - var stdOut = RunTool("", 1); + var stdOut = RunTool("", 0).Trim(); stdOut.ShouldBe(""" - Usage: dotnet asyncapi tofile [options] [startupassembly] - - startupassembly: - relative path to the application's startup assembly - - options: - --doc: name(s) of the AsyncAPI documents you want to retrieve, as configured in your startup class [defaults to all documents] - --output: relative path where the AsyncAPI will be output [defaults to stdout] - --filename: defines the file name template, {document} and {extension} template variables can be used [defaults to "{document}_asyncapi.{extension}"] - --format: exports AsyncAPI in json and/or yml format [defaults to json] - --env: define environment variable(s) for the application during generation of the AsyncAPI files [defaults to empty, can be used to define for example ASPNETCORE_ENVIRONMENT] + Usage: tofile [arguments...] [options...] [-h|--help] [--version] + + Retrieves AsyncAPI spec from a startup assembly and writes to file. + + Arguments: + [0] relative path to the application's startup assembly + + Options: + -o|--output relative path where the AsyncAPI will be output [defaults to stdout] (Default: "./") + -d|--doc name(s) of the AsyncAPI documents you want to retrieve as configured in your startup class [defaults to all documents] (Default: null) + --format exports AsyncAPI in json and/or yml format [defaults to json] (Default: "json") + --filename defines the file name template, {document} and {extension} template variables can be used [defaults to "{document}_asyncapi.{extension}\"] (Default: "{document}_asyncapi.{extension}") + --env define environment variable(s) for the application. Formatted as a comma separated list of key=value pairs or just key for flags (Default: "") """, StringCompareShould.IgnoreLineEndings); } @@ -50,7 +52,7 @@ public void StreetlightsAPIExportSpecTest() { var path = Directory.GetCurrentDirectory(); output.WriteLine($"Output path: {path}"); - var stdOut = RunTool($"--output {path} --format json,yml,yaml ../../../../../examples/StreetlightsAPI/bin/Debug/net6.0/StreetlightsAPI.dll"); + var stdOut = RunTool($"../../../../../examples/StreetlightsAPI/bin/Debug/net8.0/StreetlightsAPI.dll --output {path} --format json,yml,yaml"); stdOut.ShouldNotBeEmpty(); stdOut.ShouldContain($"AsyncAPI yaml successfully written to {Path.Combine(path, "asyncapi.yaml")}"); diff --git a/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs b/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs index f520bd10..030dbfd4 100644 --- a/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs +++ b/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs @@ -1,15 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; +using System; +using System.Linq; using System.Reflection; using Saunter.AsyncApiSchema.v2; using Saunter.Attributes; using Saunter.Generation; using Shouldly; using Xunit; -using System.Linq; namespace Saunter.Tests.Generation.DocumentGeneratorTests {