diff --git a/.editorconfig b/.editorconfig index fe3bd5da..5209be73 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 00000000..07764a78 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=lf \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bf470245..5eada2d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,12 +22,16 @@ jobs: node-version: 'lts/*' # latest LTS version - uses: actions/setup-dotnet@v2 with: - dotnet-version: '6.0.x' # SDK Version to use; x will use the latest version of the channel + dotnet-version: | + 6.0.x + 8.0.x - name: Run NPM install run: npm ci working-directory: ./src/Saunter.UI - - name: Run dotnet build + - name: Run dotnet build src + run: dotnet build ./src/Saunter-src.slnf --configuration Debug + - name: Run dotnet build all run: dotnet build --configuration Debug - name: Run dotnet test run: dotnet test --no-build diff --git a/.gitignore b/.gitignore index 758b4e30..16515532 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,7 @@ PublishScripts/ # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets +local-nuget-source/ # Microsoft Azure Build Output csx/ diff --git a/Saunter.sln b/Saunter.sln index 46277661..5df19524 100644 --- a/Saunter.sln +++ b/Saunter.sln @@ -4,6 +4,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 VisualStudioVersion = 17.0.31919.166 MinimumVisualStudioVersion = 15.0.26124.0 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{28D4C365-FDED-49AE-A97D-36202E24A55A}" + ProjectSection(SolutionItems) = preProject + src\Saunter-src.slnf = src\Saunter-src.slnf + EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Saunter", "src\Saunter\Saunter.csproj", "{240F263C-4F9B-40E0-8392-1FDB324153F6}" EndProject @@ -18,8 +21,11 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E0D34C77-924E-4F6B-9289-5A2F07D125A8}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore CHANGELOG.md = CHANGELOG.md .github\workflows\ci.yaml = .github\workflows\ci.yaml + nuget.config = nuget.config README.md = README.md .github\workflows\release.yaml = .github\workflows\release.yaml EndProjectSection @@ -28,6 +34,14 @@ 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("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AsyncAPI.Saunter.Generator.Cli", "src\AsyncAPI.Saunter.Generator.Cli\AsyncAPI.Saunter.Generator.Cli.csproj", "{6C102D4D-3DA4-4763-B75E-C15E33E7E94A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AsyncAPI.Saunter.Generator.Cli.Tests", "test\AsyncAPI.Saunter.Generator.Cli.Tests\AsyncAPI.Saunter.Generator.Cli.Tests.csproj", "{18AD0249-0436-4A26-9972-B97BA6905A54}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AsyncAPI.Saunter.Generator.Build", "src\AsyncAPI.Saunter.Generator.Build\AsyncAPI.Saunter.Generator.Build.csproj", "{A320E670-5CB0-4815-AF67-D8D09FC92A2A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AsyncAPI.Saunter.Generator.Build.Tests", "test\AsyncAPI.Saunter.Generator.Build.Tests\AsyncAPI.Saunter.Generator.Build.Tests.csproj", "{61142B10-7B49-436E-AE32-2737658BD1E5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -98,6 +112,54 @@ 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 + {18AD0249-0436-4A26-9972-B97BA6905A54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Debug|x64.ActiveCfg = Debug|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Debug|x64.Build.0 = Debug|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Debug|x86.ActiveCfg = Debug|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Debug|x86.Build.0 = Debug|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Release|Any CPU.Build.0 = Release|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Release|x64.ActiveCfg = Release|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Release|x64.Build.0 = Release|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Release|x86.ActiveCfg = Release|Any CPU + {18AD0249-0436-4A26-9972-B97BA6905A54}.Release|x86.Build.0 = Release|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Debug|x64.Build.0 = Debug|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Debug|x86.Build.0 = Debug|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Release|Any CPU.Build.0 = Release|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Release|x64.ActiveCfg = Release|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Release|x64.Build.0 = Release|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Release|x86.ActiveCfg = Release|Any CPU + {A320E670-5CB0-4815-AF67-D8D09FC92A2A}.Release|x86.Build.0 = Release|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Debug|x64.Build.0 = Debug|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Debug|x86.Build.0 = Debug|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Release|Any CPU.Build.0 = Release|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Release|x64.ActiveCfg = Release|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Release|x64.Build.0 = Release|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Release|x86.ActiveCfg = Release|Any CPU + {61142B10-7B49-436E-AE32-2737658BD1E5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -108,6 +170,10 @@ 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} + {18AD0249-0436-4A26-9972-B97BA6905A54} = {6491E321-2D02-44AB-9116-D722FE169595} + {A320E670-5CB0-4815-AF67-D8D09FC92A2A} = {28D4C365-FDED-49AE-A97D-36202E24A55A} + {61142B10-7B49-436E-AE32-2737658BD1E5} = {6491E321-2D02-44AB-9116-D722FE169595} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2F85D9DA-DBCF-4F13-8C42-5719F1469B2E} diff --git a/examples/StreetlightsAPI/.gitignore b/examples/StreetlightsAPI/.gitignore new file mode 100644 index 00000000..78c834f3 --- /dev/null +++ b/examples/StreetlightsAPI/.gitignore @@ -0,0 +1 @@ +specs/ \ No newline at end of file diff --git a/examples/StreetlightsAPI/StreetlightsAPI.csproj b/examples/StreetlightsAPI/StreetlightsAPI.csproj index 43f0b54f..dbc65c36 100644 --- a/examples/StreetlightsAPI/StreetlightsAPI.csproj +++ b/examples/StreetlightsAPI/StreetlightsAPI.csproj @@ -3,6 +3,12 @@ net6.0 false + + + true + json,yml + streetlights.{extension} + specs @@ -15,8 +21,28 @@ 1701;1702;1591 + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/local-nuget-source/readme.md b/local-nuget-source/readme.md new file mode 100644 index 00000000..fc5d5494 --- /dev/null +++ b/local-nuget-source/readme.md @@ -0,0 +1 @@ +Place holder, so the local-nuget-source exists. \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..18e0fc86 --- /dev/null +++ b/nuget.config @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/AsyncAPI.Saunter.Generator.Build/AsyncAPI.Saunter.Generator.Build.csproj b/src/AsyncAPI.Saunter.Generator.Build/AsyncAPI.Saunter.Generator.Build.csproj new file mode 100644 index 00000000..c7f4b632 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Build/AsyncAPI.Saunter.Generator.Build.csproj @@ -0,0 +1,60 @@ + + + + net6.0;net8.0 + enable + 12 + true + + AsyncAPI Build Tools + AsyncAPI Initiative + AsyncAPI.Saunter.Generator.Build + asyncapi;aspnetcore;openapi;documentation;amqp;generator;build;tool + readme.md + logo.png + https://github.com/asyncapi/saunter + true + true + true + false + https://github.com/asyncapi/saunter + MIT + false + true + 1.0.0 + bin/ + + + + 0.0.999-debug-$([System.DateTime]::Now.ToString('yyyyMMddHHmm')) + true + ../../local-nuget-source + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.props b/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.props new file mode 100644 index 00000000..f59b5b6e --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.props @@ -0,0 +1,11 @@ + + + + true + json + ./ + + + + + \ No newline at end of file diff --git a/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.targets b/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.targets new file mode 100644 index 00000000..c890b89b --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.targets @@ -0,0 +1,25 @@ + + + + + + $([System.IO.Path]::GetDirectoryName($(MSBuildThisFileDirectory))) + $([System.IO.Path]::GetDirectoryName($(AsyncAPIBuildToolBuildDir))) + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AsyncAPI.Saunter.Generator.Build/readme.md b/src/AsyncAPI.Saunter.Generator.Build/readme.md new file mode 100644 index 00000000..a8abdddd --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Build/readme.md @@ -0,0 +1,38 @@ +# AsyncApi Generator.Build Nuget Package +A nuget package to generate AsyncAPI specification files at build time, based on code-first attributes. + +This nuget packages can help to better control API changes by commiting the AsyncAPI spec to source control. By always generating spec files at build, it will be clear when the api changes. +Example to include the Generator.Build nuget package only in (local) debug builds: +``` + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + +``` + +# Customization Properties +The AsyncAPI spec generation can be configured through project properties in the csproj-file (or included via .props files): +``` + + + + + + + + +``` + +Defaults are the same as the underlying [Generator.Cli tool](https://www.nuget.org/packages/AsyncAPI.Saunter.Generator.Cli). + +If the ```AsyncAPI.Saunter.Generator.Build``` Nuget package is referenced, the default is to generate AsyncAPI spec files at build time. + +- _AsyncAPIGenerateDocumentsOnBuild_: Whether to actually generate AsyncAPI spec files on build (true or false, default: true) +- _AsyncAPIDocumentFormats_: Format of the expected AsyncAPI spec files (json, yml or yaml, default: json) +- _AsyncAPIDocumentOutputPath_: Output path for the AsyncAPI spec files, relative to the csproj location. (default is the csproj root path: ./) +- _AsyncAPIDocumentNames_: The AsyncAPI documents to generate. (default: generate all known documents) +- _AsyncAPIDocumentFilename_: Template of the AsyncAPI spec files (default: "{document}_asyncapi.{extension}") +- _AsyncAPIDocumentEnvVars_: Environment variable(s) to set during generation of the AsyncAPI spec files (default: none, Example: "ASPNETCORE_ENVIRONMENT=Development") +None of these properties are mandatory. Only referencing the [AsyncAPI.Saunter.Generator.Build](https://www.nuget.org/packages/AsyncAPI.Saunter.Generator.Build) Nuget package will generate a json AsyncAPI spec file for all AsyncAPI documents. \ No newline at end of file diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Args.cs b/src/AsyncAPI.Saunter.Generator.Cli/Args.cs new file mode 100644 index 00000000..cafc79b3 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/Args.cs @@ -0,0 +1,14 @@ +// 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 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 new file mode 100644 index 00000000..8f079e03 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj @@ -0,0 +1,55 @@ + + + + Exe + net6.0;net8.0 + enable + 12 + AsyncAPI.Saunter.Generator.Cli + + AsyncAPI Command Line Tools + AsyncAPI Initiative + true + AsyncAPI.Saunter.Generator.Cli + dotnet-asyncapi + asyncapi;aspnetcore;openapi;documentation;amqp;generator;cli;tool + readme.md + logo.png + https://github.com/asyncapi/saunter + true + true + https://github.com/asyncapi/saunter + MIT + false + true + snupkg + 1.0.1 + + + + 0.0.999-debug-$([System.DateTime]::Now.ToString('yyyyMMddHHmm')) + true + ../../local-nuget-source + + + + + + + + + + + + + + + + + + + + + + + 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 00000000..a447ce43 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/Commands/Tofile.cs @@ -0,0 +1,46 @@ +// 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 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 new file mode 100644 index 00000000..56b42dee --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs @@ -0,0 +1,203 @@ +// 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 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 Saunter.AsyncApiSchema.v2; +using static Program; +using AsyncApiDocument = Saunter.AsyncApiSchema.v2.AsyncApiDocument; +using System.IO; + +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 == 2) + { + Environment.SetEnvironmentVariable(envVar[0], envVar[1], EnvironmentVariableTarget.Process); + } + else + { + throw new ArgumentOutOfRangeException(EnvOption, namedArgs[EnvOption], "Environment variable should be in the format: env1=value1,env2=value2"); + } + } + 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) + { + var sanitizedPath = Path.GetFullPath(outputPath); + Console.WriteLine($"AsyncAPI {Path.GetExtension(outputPath)[1..]} successfully written to {sanitizedPath}"); + } + } + + 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 new file mode 100644 index 00000000..e6ef1bc3 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/Internal/DependencyResolver.cs @@ -0,0 +1,28 @@ +// 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.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 new file mode 100644 index 00000000..b9facd2b --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/Program.cs @@ -0,0 +1,39 @@ +using AsyncApi.Saunter.Generator.Cli.Commands; +using AsyncApi.Saunter.Generator.Cli.SwashbuckleImport; +using AsyncAPI.Saunter.Generator.Cli.Internal; + +DependencyResolver.Init(); + +// 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.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); 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 00000000..5b2d01eb --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/SwashbuckleImport/CommandRunner.cs @@ -0,0 +1,146 @@ +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)) + { + //_output.WriteLine($"Input: {string.Join(' ', args)}"); // Useful for debugging + 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 00000000..29d3e96e --- /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 00000000..e4635aff --- /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 00000000..babea97b --- /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 00000000..72bb6863 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/readme.md @@ -0,0 +1,27 @@ +# AsyncApi Generator.Cli Tool +A dotnet tool to generate AsyncAPI specification files based of a dotnet assembly (The application itself). + +This tool can used to manually generate spec files from a startup assembly or to setup a custom build pipeline. +If you are intressed in always generating spec files at build time, see [AsyncAPI.Saunter.Generator.Build](https://www.nuget.org/packages/AsyncAPI.Saunter.Generator.Build). + +## Tool usage +``` +dotnet asyncapi tofile --output [output-path] --format [json,yml,yaml] --doc [asyncapi-document-name] [startup-assembly] +``` +startup-assembly: the file path to the dotnet startup assembly (DLL) that hosts AsyncAPI document(s). + +## Tool options +- _--doc_: The name of the AsyncAPI document as defined in the startup class by the ```.ConfigureNamedAsyncApi()```-method. If only ```.AddAsyncApiSchemaGeneration()``` is used, the document is unnamed and will always be exported. If not specified, all documents will be exported. +- _--output_: relative path where the AsyncAPI will be output [defaults to stdout] +- _--filename_: the template for the outputted file names. Default: "{document}_asyncapi.{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. +- _--env_: define environment variable(s) for the application + +## Install the Generator.Cli dotnet Tool +``` +dotnet tool install --global AsyncAPI.Saunter.Generator.Cli +``` +After installing the tool globally, it is available using commands: ```dotnet asyncapi``` or ```dotnet-asyncapi``` + +Want to learn more about .NET tools? Or want to install it local using a manifest? +[Check out this Microsoft page on how to manage .NET tools](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools) \ No newline at end of file diff --git a/src/Saunter-src.slnf b/src/Saunter-src.slnf new file mode 100644 index 00000000..c5c27295 --- /dev/null +++ b/src/Saunter-src.slnf @@ -0,0 +1,10 @@ +{ + "solution": { + "path": "..\\Saunter.sln", + "projects": [ + "src\\AsyncAPI.Saunter.Generator.Build\\AsyncAPI.Saunter.Generator.Build.csproj", + "src\\AsyncAPI.Saunter.Generator.Cli\\AsyncAPI.Saunter.Generator.Cli.csproj", + "src\\Saunter\\Saunter.csproj" + ] + } +} \ No newline at end of file diff --git a/test/AsyncAPI.Saunter.Generator.Build.Tests/AsyncAPI.Saunter.Generator.Build.Tests.csproj b/test/AsyncAPI.Saunter.Generator.Build.Tests/AsyncAPI.Saunter.Generator.Build.Tests.csproj new file mode 100644 index 00000000..de4f84f2 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Build.Tests/AsyncAPI.Saunter.Generator.Build.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/test/AsyncAPI.Saunter.Generator.Build.Tests/StreetlightsApiBuildTests.cs b/test/AsyncAPI.Saunter.Generator.Build.Tests/StreetlightsApiBuildTests.cs new file mode 100644 index 00000000..061d4036 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Build.Tests/StreetlightsApiBuildTests.cs @@ -0,0 +1,74 @@ +// 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 AsyncAPI.Saunter.Generator.Cli.Tests; +using Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Build.Tests; + +public class StreetlightsApiBuildTests(ITestOutputHelper output) +{ + private string Run(string file, string args, string workingDirectory, int expectedExitCode = 0) + { + var process = Process.Start(new ProcessStartInfo(file) + { + Arguments = args, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }); + process.WaitForExit(TimeSpan.FromSeconds(20)); + var stdOut = process.StandardOutput.ReadToEnd().Trim(); + var stdError = process.StandardError.ReadToEnd().Trim(); + output.WriteLine($"### Output of \"{file} {args}\""); + output.WriteLine(stdOut); + output.WriteLine(stdError); + + process.ExitCode.ShouldBe(expectedExitCode); + return stdOut; + } + + private const string csprojPath = "../../../../../examples/StreetlightsAPI/StreetlightsAPI.csproj"; + + [Fact] + public void BuildingCsprojGeneratesSpecFilesTest() + { + var pwd = Directory.GetCurrentDirectory(); + var csproj = Path.GetFullPath(Path.Combine(pwd, csprojPath)); + output.WriteLine($"Current working directory: {pwd}"); + output.WriteLine($"Csproj under test: {csproj}"); + File.Exists(csproj).ShouldBeTrue(); + + var csprojDir = Path.GetDirectoryName(csproj); + var specDir = Path.Combine(csprojDir, "specs"); + + // Spec files should have been generated during the builds of the solution + Directory.GetFiles(specDir).Length.ShouldBe(2, $"#Spec files initial, path: {specDir}"); + File.ReadAllText(Path.Combine(specDir, "streetlights.yml")).ShouldBe(ExpectedSpecFiles.Yml_v2_6, "yml"); + File.ReadAllText(Path.Combine(specDir, "streetlights.json")).ShouldBe(ExpectedSpecFiles.Json_v2_6, "json"); + + // Delete spec files + foreach (var file in Directory.EnumerateFiles(specDir)) + { + File.Delete(file); + } + Directory.GetFiles(specDir).Length.ShouldBe(0, $"#Spec files after deleting them all, path: {specDir}"); + + // Run build + var stdOut = this.Run("dotnet", "build", csprojDir); + stdOut.ShouldContain($"AsyncAPI json successfully written to {Path.Combine(specDir, "streetlights.json")}"); + stdOut.ShouldContain($"AsyncAPI yml successfully written to {Path.Combine(specDir, "streetlights.yml")}"); + stdOut.ShouldContain("Build succeeded."); + stdOut.ShouldContain("0 Warning(s)"); + stdOut.ShouldContain("0 Error(s)"); + + // Check that spec files are actually re-generated + Directory.GetFiles(specDir).Length.ShouldBe(2, $"#Spec files after running build, path: {specDir}"); + File.ReadAllText(Path.Combine(specDir, "streetlights.yml")).ShouldBe(ExpectedSpecFiles.Yml_v2_6, "yml"); + File.ReadAllText(Path.Combine(specDir, "streetlights.json")).ShouldBe(ExpectedSpecFiles.Json_v2_6, "json"); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/AsyncAPI.Saunter.Generator.Cli.Tests.csproj b/test/AsyncAPI.Saunter.Generator.Cli.Tests/AsyncAPI.Saunter.Generator.Cli.Tests.csproj new file mode 100644 index 00000000..3a3489ac --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/AsyncAPI.Saunter.Generator.Cli.Tests.csproj @@ -0,0 +1,53 @@ + + + + net8.0 + enable + + false + true + + + + + + + + + + + + + PreserveNewest + true + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs new file mode 100644 index 00000000..462b2f75 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs @@ -0,0 +1,77 @@ +// 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 Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests; + +public class DotnetCliToolTests(ITestOutputHelper output) +{ + private string RunTool(string args, int expectedExitCode = 0) + { + var process = Process.Start(new ProcessStartInfo("dotnet") + { + Arguments = $"../../../../../src/AsyncAPI.Saunter.Generator.Cli/bin/Debug/net6.0/AsyncAPI.Saunter.Generator.Cli.dll tofile {args}", + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + process.WaitForExit(); + var stdOut = process.StandardOutput.ReadToEnd().Trim(); + var stdError = process.StandardError.ReadToEnd().Trim(); + output.WriteLine(stdOut); + output.WriteLine(stdError); + + process.ExitCode.ShouldBe(expectedExitCode); + //stdError.ShouldBeEmpty(); LEGO lib doesn't like id: "id is not a valid property at #/components/schemas/lightMeasuredEvent"" + return stdOut; + } + + [Fact] + public void DefaultCallPrintsCommandInfo() + { + var stdOut = RunTool("", 1); + + 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] + """, StringCompareShould.IgnoreLineEndings); + } + + [Fact] + 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"); + + stdOut.ShouldNotBeEmpty(); + stdOut.ShouldContain($"AsyncAPI yaml successfully written to {Path.Combine(path, "asyncapi.yaml")}"); + stdOut.ShouldContain($"AsyncAPI yml successfully written to {Path.Combine(path, "asyncapi.yml")}"); + stdOut.ShouldContain($"AsyncAPI json successfully written to {Path.Combine(path, "asyncapi.json")}"); + + File.Exists("asyncapi.yml").ShouldBeTrue("asyncapi.yml"); + File.Exists("asyncapi.yaml").ShouldBeTrue("asyncapi.yaml"); + File.Exists("asyncapi.json").ShouldBeTrue("asyncapi.json"); + + var yml = File.ReadAllText("asyncapi.yml"); + yml.ShouldBe(ExpectedSpecFiles.Yml_v2_6, "yaml"); + + var yaml = File.ReadAllText("asyncapi.yaml"); + yaml.ShouldBe(yml, "yml"); + + var json = File.ReadAllText("asyncapi.json"); + json.ShouldBe(ExpectedSpecFiles.Json_v2_6, "json"); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/PackAndInstallLocalTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/PackAndInstallLocalTests.cs new file mode 100644 index 00000000..79d9844d --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/PackAndInstallLocalTests.cs @@ -0,0 +1,56 @@ +// 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 Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests; + +public class PackAndInstallLocalTests(ITestOutputHelper output) +{ + private string Run(string file, string args, string workingDirectory, int expectedExitCode = 0) + { + var process = Process.Start(new ProcessStartInfo(file) + { + Arguments = args, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }); + process.WaitForExit(TimeSpan.FromSeconds(20)); + var stdOut = process.StandardOutput.ReadToEnd().Trim(); + var stdError = process.StandardError.ReadToEnd().Trim(); + output.WriteLine($"### Output of \"{file} {args}\""); + output.WriteLine(stdOut); + output.WriteLine(stdError); + + process.ExitCode.ShouldBe(expectedExitCode); + return stdOut; + } + + [Fact] + public void Pack_Install_Run_Uninstall_Test() + { + var stdOut = this.Run("dotnet", "pack", "../../../../../src/AsyncAPI.Saunter.Generator.Cli"); + stdOut.ShouldContain("Successfully created package"); + + stdOut = this.Run("dotnet", "tool install --global --add-source ./bin/Release AsyncAPI.Saunter.Generator.Cli", "../../../../../src/AsyncAPI.Saunter.Generator.Cli"); + stdOut.ShouldBeOneOf("You can invoke the tool using the following command: dotnet-asyncapi\r\nTool 'asyncapi.saunter.generator.cli' (version '1.0.1') was successfully installed.", + "Tool 'asyncapi.saunter.generator.cli' was reinstalled with the stable version (version '1.0.1')."); + + stdOut = this.Run("dotnet", "tool list -g asyncapi.saunter.generator.cli", ""); + stdOut.ShouldContain("dotnet-asyncapi"); + + stdOut = this.Run("dotnet", "asyncapi", "", 1); + stdOut.ShouldContain("tofile: retrieves AsyncAPI from a startup assembly, and writes to file"); + + stdOut = this.Run("dotnet", "tool uninstall -g asyncapi.saunter.generator.cli", ""); + stdOut.ShouldContain(" was successfully uninstalled."); + + stdOut = this.Run("dotnet", "tool list -g asyncapi.saunter.generator.cli", "", 1); + stdOut.ShouldNotContain("dotnet-asyncapi"); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/ExpectedSpecFiles.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/ExpectedSpecFiles.cs new file mode 100644 index 00000000..aa6ed033 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/ExpectedSpecFiles.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. + +namespace AsyncAPI.Saunter.Generator.Cli.Tests; + +public static class ExpectedSpecFiles +{ + public static string Json_v2_6 => File.ReadAllText("Specs/streetlights_v2.6.json"); + + public static string Yml_v2_6 => File.ReadAllText("Specs/streetlights_v2.6.yml"); +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/streetlights_v2.6.json b/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/streetlights_v2.6.json new file mode 100644 index 00000000..8a429cbb --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/streetlights_v2.6.json @@ -0,0 +1,94 @@ +{ + "asyncapi": "2.6.0", + "info": { + "title": "Streetlights API", + "version": "1.0.0", + "description": "The Smartylighting Streetlights API allows you to remotely manage the city lights.", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + } + }, + "servers": { + "mosquitto": { + "url": "test.mosquitto.org", + "protocol": "mqtt" + }, + "webapi": { + "url": "localhost:5000", + "protocol": "http" + } + }, + "defaultContentType": "application/json", + "channels": { + "publish/light/measured": { + "servers": [ + "webapi" + ], + "publish": { + "operationId": "MeasureLight", + "summary": "Inform about environmental lighting conditions for a particular streetlight.", + "tags": [ + { + "name": "Light" + } + ], + "message": { + "$ref": "#/components/messages/lightMeasuredEvent" + } + } + }, + "subscribe/light/measured": { + "servers": [ + "mosquitto" + ], + "subscribe": { + "operationId": "PublishLightMeasurement", + "summary": "Subscribe to environmental lighting conditions for a particular streetlight.", + "tags": [ + { + "name": "Light" + } + ], + "message": { + "payload": { + "$ref": "#/components/schemas/lightMeasuredEvent" + } + } + } + } + }, + "components": { + "schemas": { + "lightMeasuredEvent": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32", + "description": "Id of the streetlight." + }, + "lumens": { + "type": "integer", + "format": "int32", + "description": "Light intensity measured in lumens." + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Light intensity measured in lumens." + } + }, + "additionalProperties": false + } + }, + "messages": { + "lightMeasuredEvent": { + "payload": { + "$ref": "#/components/schemas/lightMeasuredEvent" + }, + "name": "lightMeasuredEvent" + } + } + } +} \ No newline at end of file diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/streetlights_v2.6.yml b/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/streetlights_v2.6.yml new file mode 100644 index 00000000..efccd7ed --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/streetlights_v2.6.yml @@ -0,0 +1,61 @@ +asyncapi: 2.6.0 +info: + title: Streetlights API + version: 1.0.0 + description: The Smartylighting Streetlights API allows you to remotely manage the city lights. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 +servers: + mosquitto: + url: test.mosquitto.org + protocol: mqtt + webapi: + url: localhost:5000 + protocol: http +defaultContentType: application/json +channels: + publish/light/measured: + servers: + - webapi + publish: + operationId: MeasureLight + summary: Inform about environmental lighting conditions for a particular streetlight. + tags: + - name: Light + message: + $ref: '#/components/messages/lightMeasuredEvent' + subscribe/light/measured: + servers: + - mosquitto + subscribe: + operationId: PublishLightMeasurement + summary: Subscribe to environmental lighting conditions for a particular streetlight. + tags: + - name: Light + message: + payload: + $ref: '#/components/schemas/lightMeasuredEvent' +components: + schemas: + lightMeasuredEvent: + type: object + properties: + id: + type: integer + format: int32 + description: Id of the streetlight. + lumens: + type: integer + format: int32 + description: Light intensity measured in lumens. + sentAt: + type: string + format: date-time + description: Light intensity measured in lumens. + additionalProperties: false + messages: + lightMeasuredEvent: + payload: + $ref: '#/components/schemas/lightMeasuredEvent' + name: lightMeasuredEvent \ No newline at end of file