Skip to content

Commit

Permalink
asyncapi#196 Publish a global dotnet tool
Browse files Browse the repository at this point in the history
  • Loading branch information
Senn Geerts authored and Senn Geerts committed Jul 6, 2024
1 parent 24b915d commit 6318f80
Show file tree
Hide file tree
Showing 13 changed files with 936 additions and 1 deletion.
4 changes: 3 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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}]
Expand Down
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text eol=lf
16 changes: 16 additions & 0 deletions Saunter.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
12 changes: 12 additions & 0 deletions src/AsyncApi.Saunter.Generator.Cli/Args.cs
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>12</LangVersion>
<RootNamespace>AsyncAPI.Saunter.Generator.Cli</RootNamespace>

<Description>AsyncAPI Command Line Tools</Description>
<OutputType>Exe</OutputType>
<PackAsTool>true</PackAsTool>
<PackageId>AsyncAPI.Saunter.Generator.Cli</PackageId>
<!--<TargetFrameworks>net6.0;net7.0;net8.0</TargetFrameworks>-->
<ToolCommandName>asyncapi</ToolCommandName>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>

<ItemGroup Condition=" '$(TargetFramework)' != 'netstandard2.0' ">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="AsyncAPI.NET.Readers" Version="5.2.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Saunter\Saunter.csproj" />
</ItemGroup>

</Project>
54 changes: 54 additions & 0 deletions src/AsyncApi.Saunter.Generator.Cli/Commands/Tofile.cs
Original file line number Diff line number Diff line change
@@ -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<IDictionary<string, string>, 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;
}
}
183 changes: 183 additions & 0 deletions src/AsyncApi.Saunter.Generator.Cli/Commands/TofileInternal.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> 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<IAsyncApiDocumentProvider>();
var asyncapiOptions = serviceProvider.GetService<IOptions<AsyncApiOptions>>();
var documentSerializer = serviceProvider.GetRequiredService<IAsyncApiDocumentSerializer>();

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<Stream> 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<THost>(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;
}
}
32 changes: 32 additions & 0 deletions src/AsyncApi.Saunter.Generator.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -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);
Loading

0 comments on commit 6318f80

Please sign in to comment.