From 6318f80c42cc56809ac291d4bc185e521e9e7d2e Mon Sep 17 00:00:00 2001 From: Senn Geerts Date: Sat, 6 Jul 2024 14:27:12 +0200 Subject: [PATCH] #196 Publish a global dotnet tool --- .editorconfig | 4 +- .gitattributes | 1 + Saunter.sln | 16 + src/AsyncApi.Saunter.Generator.Cli/Args.cs | 12 + .../AsyncApi.Saunter.Generator.Cli.csproj | 31 ++ .../Commands/Tofile.cs | 54 +++ .../Commands/TofileInternal.cs | 183 ++++++++++ src/AsyncApi.Saunter.Generator.Cli/Program.cs | 32 ++ .../SwashbuckleImport/CommandRunner.cs | 145 ++++++++ .../SwashbuckleImport/HostFactoryResolver.cs | 325 ++++++++++++++++++ .../SwashbuckleImport/HostingApplication.cs | 118 +++++++ .../SwashbuckleImport/readme.md | 3 + src/AsyncApi.Saunter.Generator.Cli/readme.md | 13 + 13 files changed, 936 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 src/AsyncApi.Saunter.Generator.Cli/Args.cs create mode 100644 src/AsyncApi.Saunter.Generator.Cli/AsyncApi.Saunter.Generator.Cli.csproj create mode 100644 src/AsyncApi.Saunter.Generator.Cli/Commands/Tofile.cs create mode 100644 src/AsyncApi.Saunter.Generator.Cli/Commands/TofileInternal.cs create mode 100644 src/AsyncApi.Saunter.Generator.Cli/Program.cs create mode 100644 src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs create mode 100644 src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/HostFactoryResolver.cs create mode 100644 src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/HostingApplication.cs create mode 100644 src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/readme.md create mode 100644 src/AsyncApi.Saunter.Generator.Cli/readme.md diff --git a/.editorconfig b/.editorconfig index fe3bd5d..5209be7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,10 +14,12 @@ spelling_exclusion_path = SpellingExclusions.dic indent_size = 4 insert_final_newline = true charset = utf-8-bom +end_of_line = lf # XML project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] -indent_size = 4 +indent_size = 2 +end_of_line = lf # XML config files [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..07764a7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=lf \ No newline at end of file diff --git a/Saunter.sln b/Saunter.sln index 4627766..bd69f05 100644 --- a/Saunter.sln +++ b/Saunter.sln @@ -18,6 +18,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E0D34C77-924E-4F6B-9289-5A2F07D125A8}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .gitattributes = .gitattributes CHANGELOG.md = CHANGELOG.md .github\workflows\ci.yaml = .github\workflows\ci.yaml README.md = README.md @@ -28,6 +29,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Saunter.IntegrationTests.Re EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Saunter.Tests.MarkerTypeTests", "test\Saunter.Tests.MarkerTypeTests\Saunter.Tests.MarkerTypeTests.csproj", "{02284473-6DE7-4EE0-8433-2AC295045549}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AsyncAPI.Saunter.Generator.Cli", "src\AsyncApi.Saunter.Generator.Cli\AsyncAPI.Saunter.Generator.Cli.csproj", "{6C102D4D-3DA4-4763-B75E-C15E33E7E94A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -98,6 +101,18 @@ Global {02284473-6DE7-4EE0-8433-2AC295045549}.Release|x64.Build.0 = Release|Any CPU {02284473-6DE7-4EE0-8433-2AC295045549}.Release|x86.ActiveCfg = Release|Any CPU {02284473-6DE7-4EE0-8433-2AC295045549}.Release|x86.Build.0 = Release|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Debug|x64.ActiveCfg = Debug|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Debug|x64.Build.0 = Debug|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Debug|x86.ActiveCfg = Debug|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Debug|x86.Build.0 = Debug|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Release|Any CPU.Build.0 = Release|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Release|x64.ActiveCfg = Release|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Release|x64.Build.0 = Release|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Release|x86.ActiveCfg = Release|Any CPU + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -108,6 +123,7 @@ Global {F188D4A7-BBCB-464F-A370-2BD84D18EA79} = {6ABD4842-47AF-49A5-B057-0EBA64416789} {7CD09B89-130A-41AF-ADAE-2166C4ED695B} = {6491E321-2D02-44AB-9116-D722FE169595} {02284473-6DE7-4EE0-8433-2AC295045549} = {6491E321-2D02-44AB-9116-D722FE169595} + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A} = {28D4C365-FDED-49AE-A97D-36202E24A55A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2F85D9DA-DBCF-4F13-8C42-5719F1469B2E} diff --git a/src/AsyncApi.Saunter.Generator.Cli/Args.cs b/src/AsyncApi.Saunter.Generator.Cli/Args.cs new file mode 100644 index 0000000..243315d --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/Args.cs @@ -0,0 +1,12 @@ +// 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. + +// ReSharper disable once CheckNamespace +public static partial class Program +{ + internal const string StartupAssemblyArgument = "startupassembly"; + internal const string DocArgument = "doc"; + internal const string FormatOption = "--format"; + internal const string OutputOption = "--output"; +} diff --git a/src/AsyncApi.Saunter.Generator.Cli/AsyncApi.Saunter.Generator.Cli.csproj b/src/AsyncApi.Saunter.Generator.Cli/AsyncApi.Saunter.Generator.Cli.csproj new file mode 100644 index 0000000..bf10ee0 --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/AsyncApi.Saunter.Generator.Cli.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + Exe + enable + 12 + AsyncAPI.Saunter.Generator.Cli + + AsyncAPI Command Line Tools + Exe + true + AsyncAPI.Saunter.Generator.Cli + + asyncapi + true + + + + + + + + + + + + + + + diff --git a/src/AsyncApi.Saunter.Generator.Cli/Commands/Tofile.cs b/src/AsyncApi.Saunter.Generator.Cli/Commands/Tofile.cs new file mode 100644 index 0000000..dda464f --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/Commands/Tofile.cs @@ -0,0 +1,54 @@ +// 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.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 subProcessCommandLine = + $"exec --depsfile {EscapePath(depsFile)} " + + $"--runtimeconfig {EscapePath(runtimeConfig)} " + + $"--additional-deps AsyncAPI.Saunter.Generator.Cli.deps.json " + + //$"--additionalprobingpath {EscapePath(typeof(Program).GetTypeInfo().Assembly.Location)} " + + $"{EscapePath(typeof(Program).GetTypeInfo().Assembly.Location)} " + + $"_{commandName} {string.Join(" ", subProcessArguments.Select(EscapePath))}"; + + try + { + var subProcess = Process.Start("dotnet", subProcessCommandLine); + subProcess.WaitForExit(); + return subProcess.ExitCode; + } + catch (Exception e) + { + throw new Exception("Running internal _tofile failed.", e); + } + }; + + private static string EscapePath(string path) + { + return path.Contains(' ') ? "\"" + path + "\"" : path; + } +} diff --git a/src/AsyncApi.Saunter.Generator.Cli/Commands/TofileInternal.cs b/src/AsyncApi.Saunter.Generator.Cli/Commands/TofileInternal.cs new file mode 100644 index 0000000..616769b --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/Commands/TofileInternal.cs @@ -0,0 +1,183 @@ +// 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 LEGO.AsyncAPI.Readers; +using Microsoft.Extensions.Options; +using Saunter.Serialization; +using Saunter; +using System.Runtime.Loader; +using System.Reflection; +using LEGO.AsyncAPI; +using LEGO.AsyncAPI.Models; +using Microsoft.Extensions.DependencyInjection; +using AsyncApi.Saunter.Generator.Cli.SwashbuckleImport; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Hosting; +using static Program; + +namespace AsyncApi.Saunter.Generator.Cli.Commands; + +internal class TofileInternal +{ + 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 serviceProvider = GetServiceProvider(startupAssembly); + + // 3) Retrieve AsyncAPI via configured provider + var documentProvider = serviceProvider.GetService(); + var asyncapiOptions = serviceProvider.GetService>(); + var documentSerializer = serviceProvider.GetRequiredService(); + + if (!asyncapiOptions.Value.NamedApis.TryGetValue(namedArgs[DocArgument], out var prototype)) + { + throw new ArgumentOutOfRangeException(DocArgument, namedArgs[DocArgument], $"Requested AsyncAPI document not found: '{namedArgs[DocArgument]}'. Known document(s): {string.Join(", ", asyncapiOptions.Value.NamedApis.Keys)}."); + } + var asyncApiSchema = documentProvider.GetDocument(asyncapiOptions.Value, prototype); + var asyncApiSchemaJson = documentSerializer.Serialize(asyncApiSchema); + var asyncApiDocument = new AsyncApiStringReader().Read(asyncApiSchemaJson, out var diagnostic); + if (diagnostic.Errors.Any()) + { + Console.Error.WriteLine($"AsyncAPI Schema 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 arg1) ? Path.Combine(Directory.GetCurrentDirectory(), arg1) : null; + + if (!string.IsNullOrEmpty(outputPath)) + { + var directoryPath = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + } + + var exportJson = true; + var exportYml = false; + var exportYaml = false; + if (namedArgs.TryGetValue(FormatOption, out var 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, "json"), stream => asyncApiDocument.SerializeAsJson(stream, AsyncApiVersion.AsyncApi2_0)); + } + + if (exportYml) + { + WriteFile(AddFileExtension(outputPath, "yml"), stream => asyncApiDocument.SerializeAsYaml(stream, AsyncApiVersion.AsyncApi2_0)); + } + + if (exportYaml) + { + WriteFile(AddFileExtension(outputPath, "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 extension) + { + if (outputPath == null) + { + return outputPath; + } + + if (outputPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + { + return outputPath; + } + + return $"{TrimEnd(outputPath, ".json", ".yml", ".yaml")}.{extension}"; + } + + private static string TrimEnd(string str, params string[] trims) + { + foreach (var trim in trims) + { + if (str.EndsWith(trim, StringComparison.OrdinalIgnoreCase)) + { + str = str[..^trim.Length]; + } + } + return str; + } + + 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/Program.cs b/src/AsyncApi.Saunter.Generator.Cli/Program.cs new file mode 100644 index 0000000..33471b7 --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/Program.cs @@ -0,0 +1,32 @@ +using AsyncApi.Saunter.Generator.Cli.Commands; +using AsyncApi.Saunter.Generator.Cli.SwashbuckleImport; + +// Helper to simplify command line parsing etc. +var runner = new CommandRunner("dotnet asyncapi", "AsyncAPI Command Line Tools", Console.Out); + +// 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.Argument(DocArgument, "name of the AsyncAPI doc you want to retrieve, as configured in your startup class"); + c.Option(OutputOption, "relative path where the AsyncAPI will be output, defaults to stdout"); + c.Option(FormatOption, "exports AsyncAPI in json and/or yml format [Default json]"); + c.OnRun(Tofile.Run(args)); +}); + +// > dotnet asyncapi _tofile ... (* should only be invoked via "dotnet exec") +runner.SubCommand("_tofile", "", c => +{ + c.Argument(StartupAssemblyArgument, ""); + c.Argument(DocArgument, ""); + c.Option(OutputOption, ""); + c.Option(FormatOption, ""); + c.OnRun(TofileInternal.Run); +}); + +return runner.Run(args); diff --git a/src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs b/src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs new file mode 100644 index 0000000..c3c8eca --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs @@ -0,0 +1,145 @@ +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 new file mode 100644 index 0000000..29d3e96 --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/HostFactoryResolver.cs @@ -0,0 +1,325 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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 new file mode 100644 index 0000000..e4635af --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/HostingApplication.cs @@ -0,0 +1,118 @@ +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 new file mode 100644 index 0000000..babea97 --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/SwashbuckleImport/readme.md @@ -0,0 +1,3 @@ +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/readme.md b/src/AsyncApi.Saunter.Generator.Cli/readme.md new file mode 100644 index 0000000..afb7f45 --- /dev/null +++ b/src/AsyncApi.Saunter.Generator.Cli/readme.md @@ -0,0 +1,13 @@ +# AsyncApi Generator.Cli Tool + +## Tool usage +``` +dotnet asyncapi tofile --output [output-path] --format [json,yml,yaml] [startup-assembly] [asyncapi-document-name] +``` + +## Tool options +startup-assembly: the file path to the entrypoint dotnet DLL that hosts AsyncAPI document(s). +asyncapi-document-name: (optional) The name of the AsyncAPI document as defined in the startup class by the ```.ConfigureNamedAsyncApi()```-method. If not specified, all documents will be exported. + +--output: the output path or the file name. File extension can be omitted, as the --format file determine the file extension. +--format: the output formats to generate, can be a combination of json, yml and/or yaml. File extension is appended to the output path.