diff --git a/.editorconfig b/.editorconfig index fe3bd5da..20f2dfdc 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}] @@ -142,8 +144,6 @@ dotnet_naming_symbols.all_members.applicable_kinds = * dotnet_naming_style.pascal_case_style.capitalization = pascal_case -file_header_template = Licensed to the .NET Foundation under one or more agreements.\nThe .NET Foundation licenses this file to you under the MIT license.\nSee the LICENSE file in the project root for more information. - # RS0016: Only enable if API files are present dotnet_public_api_analyzer.require_api_files = true 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/dotnet/action.yaml b/.github/dotnet/action.yaml index 47858e0c..03696ad9 100644 --- a/.github/dotnet/action.yaml +++ b/.github/dotnet/action.yaml @@ -7,4 +7,6 @@ runs: id: setup-dotnet uses: actions/setup-dotnet@v1 with: - dotnet-version: "6.0.x" + dotnet-version: | + 6.0.x + 8.0.x diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9ab2a66..ec47f69c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,7 +35,4 @@ jobs: - name: setup build uses: ./.github/npm - name: unit test - run: dotnet test ./test/Saunter.Tests/Saunter.Tests.csproj - # TODO: why there are 2 of them.... - - name: unit mark test - run: dotnet test ./test/Saunter.Tests.MarkerTypeTests/Saunter.Tests.MarkerTypeTests.csproj + run: dotnet test --configuration Debug diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c1c33107..452effd8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,7 @@ jobs: # Gets the numeric version from a tag (e.g. v1.2.3 -> 1.2.3) run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Create Nuget package - run: dotnet pack ./src/Saunter/Saunter.csproj --configuration Release -p:Version="$RELEASE_VERSION" --output ./build + run: dotnet pack --configuration Release -p:Version="$RELEASE_VERSION" --output ./build - name: Push Nuget package to Nuget.org env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} diff --git a/.gitignore b/.gitignore index 758b4e30..dd3e6915 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,7 @@ PublishScripts/ # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets +dotnet-tools.json # Microsoft Azure Build Output csx/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..2451c08c --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Saunter.sln b/Saunter.sln index 038ce2f3..60ff6a88 100644 --- a/Saunter.sln +++ b/Saunter.sln @@ -12,13 +12,18 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Saunter.Tests", "test\Saunter.Tests\Saunter.Tests.csproj", "{3ADB27EF-7C80-40EB-AFC6-5D06D415FFAB}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{6ABD4842-47AF-49A5-B057-0EBA64416789}" + ProjectSection(SolutionItems) = preProject + examples\.gitignore = examples\.gitignore + EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StreetlightsAPI", "examples\StreetlightsAPI\StreetlightsAPI.csproj", "{F188D4A7-BBCB-464F-A370-2BD84D18EA79}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{E0D34C77-924E-4F6B-9289-5A2F07D125A8}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .gitattributes = .gitattributes CHANGELOG.md = CHANGELOG.md + Directory.Build.props = Directory.Build.props README.md = README.md EndProjectSection EndProject @@ -42,6 +47,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "npm", "npm", "{E8FACA22-CFE .github\npm\action.yaml = .github\npm\action.yaml EndProjectSection 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}") = "StreetlightsAPI.TopLevelStatement", "examples\StreetlightsAPI.TopLevelStatement\StreetlightsAPI.TopLevelStatement.csproj", "{6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -112,6 +123,42 @@ 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 + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Debug|x64.Build.0 = Debug|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Debug|x86.Build.0 = Debug|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Release|Any CPU.Build.0 = Release|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Release|x64.ActiveCfg = Release|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Release|x64.Build.0 = Release|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Release|x86.ActiveCfg = Release|Any CPU + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -124,6 +171,9 @@ Global {02284473-6DE7-4EE0-8433-2AC295045549} = {6491E321-2D02-44AB-9116-D722FE169595} {69459F9D-DA73-4E84-8BA7-4CE03E2B7664} = {D8CB9C0D-9605-457B-979F-C8994B20A926} {E8FACA22-CFED-4710-89E4-D55F31BF96B3} = {D8CB9C0D-9605-457B-979F-C8994B20A926} + {6C102D4D-3DA4-4763-B75E-C15E33E7E94A} = {28D4C365-FDED-49AE-A97D-36202E24A55A} + {18AD0249-0436-4A26-9972-B97BA6905A54} = {6491E321-2D02-44AB-9116-D722FE169595} + {6F6B8B03-9045-46EC-AE12-E7ADA492F9FA} = {6ABD4842-47AF-49A5-B057-0EBA64416789} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2F85D9DA-DBCF-4F13-8C42-5719F1469B2E} diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 00000000..f91a1165 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,4 @@ +specs/ +streetlights.json +streetlights.yml +streetlights.yaml \ No newline at end of file diff --git a/examples/StreetlightsAPI.TopLevelStatement/Program.cs b/examples/StreetlightsAPI.TopLevelStatement/Program.cs new file mode 100644 index 00000000..2e59f1cb --- /dev/null +++ b/examples/StreetlightsAPI.TopLevelStatement/Program.cs @@ -0,0 +1,79 @@ +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NLog; +using NLog.Web; +using Saunter; +using Saunter.AsyncApiSchema.v2; +using StreetlightsAPI; + +LogManager.Setup().LoadConfigurationFromAppSettings(); + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.AddSimpleConsole(console => console.SingleLine = true); +builder.Host.UseNLog(); + +// Add Saunter to the application services. +builder.Services.AddAsyncApiSchemaGeneration(options => +{ + options.AssemblyMarkerTypes = [typeof(StreetlightMessageBus)]; + + options.Middleware.UiTitle = "Streetlights API"; + + options.AsyncApi = new AsyncApiDocument + { + Info = new Info("Streetlights API", "1.0.0") + { + Description = "The Smartylighting Streetlights API allows you to remotely manage the city lights.", + License = new License("Apache 2.0") + { + Url = "https://www.apache.org/licenses/LICENSE-2.0" + } + }, + Servers = + { + ["mosquitto"] = new Server("test.mosquitto.org", "mqtt"), + ["webapi"] = new Server("localhost:5000", "http"), + }, + }; +}); + +builder.Services.AddScoped(); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.UseDeveloperExceptionPage(); + +app.UseRouting(); +app.UseCors(configure => configure.AllowAnyOrigin().AllowAnyMethod()); + +// to be fixed with issue #173 +#pragma warning disable ASP0014 // Suggest using top level route registrations instead of UseEndpoints +app.UseEndpoints(endpoints => +{ + endpoints.MapAsyncApiDocuments(); + endpoints.MapAsyncApiUi(); + + endpoints.MapControllers(); +}); +#pragma warning restore ASP0014 // Suggest using top level route registrations instead of UseEndpoints + +await app.StartAsync(); + +// Print the AsyncAPI doc location +var logger = app.Services.GetService().CreateLogger(); +var options = app.Services.GetService>(); +var addresses = app.Urls; +logger.LogInformation("AsyncAPI doc available at: {URL}", $"{addresses.FirstOrDefault()}{options.Value.Middleware.Route}"); +logger.LogInformation("AsyncAPI UI available at: {URL}", $"{addresses.FirstOrDefault()}{options.Value.Middleware.UiBaseRoute}"); + +// Redirect base url to AsyncAPI UI +app.Map("/", () => Results.Redirect("index.html")); +app.Map("/index.html", () => Results.Redirect(options.Value.Middleware.UiBaseRoute)); + +await app.WaitForShutdownAsync(); diff --git a/examples/StreetlightsAPI.TopLevelStatement/StreetlightsAPI.TopLevelStatement.csproj b/examples/StreetlightsAPI.TopLevelStatement/StreetlightsAPI.TopLevelStatement.csproj new file mode 100644 index 00000000..4e4dfcc0 --- /dev/null +++ b/examples/StreetlightsAPI.TopLevelStatement/StreetlightsAPI.TopLevelStatement.csproj @@ -0,0 +1,46 @@ + + + + + net8.0 + false + + + true + json,yml + streetlights.{extension} + specs + + + + bin\Debug\StreetlightsAPI.TopLevelStatement.xml + 1701;1702;1591 + + + + bin\Release\StreetlightsAPI.TopLevelStatement.xml + 1701;1702;1591 + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + diff --git a/examples/StreetlightsAPI.TopLevelStatement/appsettings.json b/examples/StreetlightsAPI.TopLevelStatement/appsettings.json new file mode 100644 index 00000000..b4f6487b --- /dev/null +++ b/examples/StreetlightsAPI.TopLevelStatement/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + + "AllowedHosts": "*", + + "Kestrel": { + "EndPoints": { + "Http": { + "Url": "http://localhost:5001" + } + } + } +} diff --git a/examples/StreetlightsAPI/Program.cs b/examples/StreetlightsAPI/Program.cs index aa3f77ad..9afd805e 100644 --- a/examples/StreetlightsAPI/Program.cs +++ b/examples/StreetlightsAPI/Program.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NLog; +using NLog.Web; using Saunter; using Saunter.AsyncApiSchema.v2; @@ -15,6 +17,8 @@ public class Program { public static void Main(string[] args) { + LogManager.Setup().LoadConfigurationFromAppSettings(); + CreateHostBuilder(args).Build().Run(); } @@ -22,10 +26,11 @@ public static IHostBuilder CreateHostBuilder(string[] args) { return Host.CreateDefaultBuilder(args) .ConfigureLogging(logging => logging.AddSimpleConsole(console => console.SingleLine = true)) + .UseNLog() .ConfigureWebHostDefaults(web => { web.UseStartup(); - web.UseUrls("http://localhost:5000"); + web.UseUrls("http://localhost:5001"); }); } } diff --git a/examples/StreetlightsAPI/StreetlightsAPI.csproj b/examples/StreetlightsAPI/StreetlightsAPI.csproj index 43f0b54f..ab72e944 100644 --- a/examples/StreetlightsAPI/StreetlightsAPI.csproj +++ b/examples/StreetlightsAPI/StreetlightsAPI.csproj @@ -1,6 +1,8 @@  + net6.0 false @@ -19,4 +21,17 @@ + + + + + PreserveNewest + + + + + + + + diff --git a/examples/StreetlightsAPI/nlog.config b/examples/StreetlightsAPI/nlog.config new file mode 100644 index 00000000..dd855146 --- /dev/null +++ b/examples/StreetlightsAPI/nlog.config @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..3c902f75 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj @@ -0,0 +1,57 @@ + + + + Exe + net8.0 + enable + 12 + AsyncAPI.Saunter.Generator.Cli + $(NoWarn);EF1001 + + AsyncAPI Command Line Tools: Dotnet tool to generate AsyncAPI spec file(s) from dotnet startup assembly. + AsyncAPI Initiative + true + AsyncAPI.Saunter.Generator.Cli + DotnetTool + 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 + + + + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Program.cs b/src/AsyncAPI.Saunter.Generator.Cli/Program.cs new file mode 100644 index 00000000..58bfef22 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/Program.cs @@ -0,0 +1,19 @@ +using AsyncAPI.Saunter.Generator.Cli.ToFile; +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +var services = new ServiceCollection(); +services.AddLogging(builder => builder.AddSimpleConsole(x => x.SingleLine = true).SetMinimumLevel(LogLevel.Trace)); +services.AddToFileCommand(); + +using var serviceProvider = services.BuildServiceProvider(); +var logger = serviceProvider.GetRequiredService>(); +ConsoleApp.LogError = msg => logger.LogError(msg); +ConsoleApp.ServiceProvider = serviceProvider; + +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +Environment.ExitCode = 0; diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/AsyncApiDocumentExtractor.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/AsyncApiDocumentExtractor.cs new file mode 100644 index 00000000..6a1709fb --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/AsyncApiDocumentExtractor.cs @@ -0,0 +1,72 @@ +using LEGO.AsyncAPI.Models; +using LEGO.AsyncAPI.Readers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Saunter; +using Saunter.Serialization; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal interface IAsyncApiDocumentExtractor +{ + IEnumerable<(string name, AsyncApiDocument document)> GetAsyncApiDocument(IServiceProvider serviceProvider, string[] requestedDocuments); +} + +internal class AsyncApiDocumentExtractor(ILogger logger) : IAsyncApiDocumentExtractor +{ + private IEnumerable GetDocumentNames(string[] requestedDocuments, AsyncApiOptions asyncApiOptions) + { + var documentNames = requestedDocuments ?? asyncApiOptions.NamedApis.Keys; + if (documentNames.Count == 0) + { + if (asyncApiOptions.AssemblyMarkerTypes.Any()) + { + documentNames = [null]; // null marks the default, unnamed, document + } + else + { + logger.LogCritical($"AsyncAPI documents found. Known named document(s): {string.Join(", ", asyncApiOptions.NamedApis.Keys)}."); + } + } + return documentNames; + } + + public IEnumerable<(string name, AsyncApiDocument document)> GetAsyncApiDocument(IServiceProvider serviceProvider, string[] requestedDocuments) + { + var documentProvider = serviceProvider.GetService(); + var asyncApiOptions = serviceProvider.GetService>().Value; + var documentSerializer = serviceProvider.GetRequiredService(); + + foreach (var documentName in GetDocumentNames(requestedDocuments, asyncApiOptions)) + { + if (documentName == null || !asyncApiOptions.NamedApis.TryGetValue(documentName, out var prototype)) + { + prototype = asyncApiOptions.AsyncApi; + } + + var schema = documentProvider.GetDocument(asyncApiOptions, prototype); + var asyncApiSchemaJson = documentSerializer.Serialize(schema); + var asyncApiDocument = new AsyncApiStringReader().Read(asyncApiSchemaJson, out var diagnostic); + if (diagnostic.Errors.Any()) + { + logger.LogError($"AsyncAPI Schema '{documentName ?? "default"}' is not valid ({diagnostic.Errors.Count} Error(s))"); + foreach (var error in diagnostic.Errors) + { + logger.LogError($"- {error}"); + } + } + if (diagnostic.Warnings.Any()) + { + logger.LogWarning($"AsyncAPI Schema '{documentName ?? "default"}' has {diagnostic.Warnings.Count} Warning(s):"); + foreach (var warning in diagnostic.Warnings) + { + logger.LogWarning($"- {warning}"); + } + } + + yield return (documentName, asyncApiDocument); + } + } +} + diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/DependencyResolver.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/DependencyResolver.cs new file mode 100644 index 00000000..3d6d010a --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/DependencyResolver.cs @@ -0,0 +1,22 @@ +using System.Reflection; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal static class DependencyResolver +{ + public static void Init(string startupAssemblyBasePath) + { + AppDomain.CurrentDomain.AssemblyResolve += (sender, args) => + { + var requestedAssembly = new AssemblyName(args.Name); + var fullPath = Path.Combine(startupAssemblyBasePath, $"{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/ToFile/Environment.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/Environment.cs new file mode 100644 index 00000000..2ae66e79 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/Environment.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal interface IEnvironmentBuilder +{ + void SetEnvironmentVariables(string env); +} + +internal class EnvironmentBuilder(ILogger logger) : IEnvironmentBuilder +{ + public void SetEnvironmentVariables(string env) + { + var envVars = !string.IsNullOrWhiteSpace(env) ? env.Split(',').Select(x => x.Trim()) : Array.Empty(); + var keyValues = envVars.Select(x => x.Split('=').Select(x => x.Trim()).ToList()); + foreach (var envVar in keyValues) + { + if (envVar.Count == 2 && !string.IsNullOrWhiteSpace(envVar[0])) + { + Environment.SetEnvironmentVariable(envVar[0], envVar[1], EnvironmentVariableTarget.Process); + logger.LogDebug($"Set environment variable: {envVar[0]} = {envVar[1]}"); + } + else + { + logger.LogCritical("Environment variables should be in the format: env1=value1,env2=value2"); + } + } + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/FileWriter.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/FileWriter.cs new file mode 100644 index 00000000..d58060ea --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/FileWriter.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal interface IFileWriter +{ + void Write(string outputPath, string fileTemplate, string documentName, string format, Action streamWriter); +} + +internal class FileWriter(IStreamProvider streamProvider, ILogger logger) : IFileWriter +{ + public void Write(string outputPath, string fileTemplate, string documentName, string format, Action streamWriter) + { + var fullFileName = AddFileExtension(outputPath, fileTemplate, documentName, format); + this.WriteFile(fullFileName, streamWriter); + } + + private void WriteFile(string outputPath, Action writeAction) + { + using var stream = streamProvider.GetStreamFor(outputPath); + writeAction(stream); + + if (outputPath != null) + { + logger.LogInformation($"AsyncAPI {Path.GetExtension(outputPath).TrimStart('.')} successfully written to {outputPath}"); + } + } + + private static string AddFileExtension(string outputPath, string fileTemplate, string documentName, string extension) + { + if (outputPath == null) + { + return outputPath; + } + + return Path.GetFullPath(Path.Combine(outputPath, fileTemplate.Replace("{document}", documentName ?? "") + .Replace("{extension}", extension).TrimStart('_'))); + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceExtensions.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceExtensions.cs new file mode 100644 index 00000000..33201f1e --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal static class ServiceExtensions +{ + public static IServiceCollection AddToFileCommand(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + return services; + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceProviderBuilder.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceProviderBuilder.cs new file mode 100644 index 00000000..eed4c58b --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ServiceProviderBuilder.cs @@ -0,0 +1,33 @@ +using System.Runtime.Loader; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal interface IServiceProviderBuilder +{ + IServiceProvider BuildServiceProvider(string startupAssembly); +} + +internal class ServiceProviderBuilder(ILogger logger) : IServiceProviderBuilder +{ + public IServiceProvider BuildServiceProvider(string startupAssembly) + { + var fullPath = Path.GetFullPath(startupAssembly); + var basePath = Path.GetDirectoryName(fullPath); + DependencyResolver.Init(basePath); + + logger.LogInformation($"Loading startup assembly: {fullPath}"); + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath); + var reporter = new OperationReporter(new OperationReportHandler( + m => logger.LogError(m), + m => logger.LogWarning(m), + m => logger.LogInformation(m), + m => logger.LogDebug(m))); + var appServiceProvider = new AppServiceProviderFactory(assembly, reporter); + var serviceProvider = appServiceProvider.Create([]); + + return serviceProvider; + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/StreamProvider.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/StreamProvider.cs new file mode 100644 index 00000000..7ada6199 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/StreamProvider.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal interface IStreamProvider +{ + Stream GetStreamFor(string path); +} + +internal class StreamProvider(ILogger logger) : IStreamProvider +{ + public Stream GetStreamFor(string path) + { + logger.LogDebug($"GetStreamFor(path: {path})"); + + if (!string.IsNullOrEmpty(path)) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + } + + return path != null ? File.Create(path) : Console.OpenStandardOutput(); + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ToFileCommand.cs b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ToFileCommand.cs new file mode 100644 index 00000000..91055b47 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/ToFile/ToFileCommand.cs @@ -0,0 +1,73 @@ +using ConsoleAppFramework; +using LEGO.AsyncAPI; +using LEGO.AsyncAPI.Models; +using Microsoft.Extensions.Logging; + +namespace AsyncAPI.Saunter.Generator.Cli.ToFile; + +internal class ToFileCommand(ILogger logger, IEnvironmentBuilder environment, IServiceProviderBuilder builder, IAsyncApiDocumentExtractor docExtractor, IFileWriter fileWriter) +{ + private const string DEFAULT_FILENAME = "{document}_asyncapi.{extension}"; + + /// + /// Retrieves AsyncAPI spec from a startup assembly and writes to file. + /// + /// relative path to the application's startup assembly + /// -o,relative path where the AsyncAPI documents will be exported to + /// -d,name(s) of the AsyncAPI documents you want to export as configured in your startup class. To export all documents using null. + /// exports AsyncAPI in json and/or yml format + /// defines the file name template, {document} and {extension} template variables can be used + /// define environment variable(s) for the application. Formatted as a comma separated list of _key=value_ pairs + [Command("tofile")] + public int ToFile([Argument] string startupassembly, string output = "./", string doc = null, string format = "json", string filename = DEFAULT_FILENAME, string env = "") + { + if (!File.Exists(startupassembly)) + { + throw new FileNotFoundException(startupassembly); + } + + // Prepare environment + environment.SetEnvironmentVariables(env); + + // Get service provider from startup assembly + var serviceProvider = builder.BuildServiceProvider(startupassembly); + + // Retrieve AsyncAPI via service provider + var documents = docExtractor.GetAsyncApiDocument(serviceProvider, !string.IsNullOrWhiteSpace(doc) ? doc.Split(',', StringSplitOptions.RemoveEmptyEntries) : null); + foreach (var (documentName, asyncApiDocument) in documents) + { + // Serialize to specified output location or stdout + var outputPath = !string.IsNullOrWhiteSpace(output) ? Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), output)) : null; + + var exportJson = true; + var exportYml = false; + var exportYaml = false; + if (!string.IsNullOrWhiteSpace(format)) + { + var splitted = format.Split(',').Select(x => x.Trim()).ToList(); + exportJson = splitted.Any(x => x.Equals("json", StringComparison.OrdinalIgnoreCase)); + exportYml = splitted.Any(x => x.Equals("yml", StringComparison.OrdinalIgnoreCase)); + exportYaml = splitted.Any(x => x.Equals("yaml", StringComparison.OrdinalIgnoreCase)); + } + logger.LogDebug($"Format: exportJson={exportJson}, exportYml={exportYml}, exportYaml={exportYaml}."); + + var fileTemplate = !string.IsNullOrWhiteSpace(filename) ? filename : DEFAULT_FILENAME; + if (exportJson) + { + fileWriter.Write(outputPath, fileTemplate, documentName, "json", stream => asyncApiDocument.SerializeAsJson(stream, AsyncApiVersion.AsyncApi2_0)); + } + + if (exportYml) + { + fileWriter.Write(outputPath, fileTemplate, documentName, "yml", stream => asyncApiDocument.SerializeAsYaml(stream, AsyncApiVersion.AsyncApi2_0)); + } + + if (exportYaml) + { + fileWriter.Write(outputPath, fileTemplate, documentName, "yaml", stream => asyncApiDocument.SerializeAsYaml(stream, AsyncApiVersion.AsyncApi2_0)); + } + } + + return 0; + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/readme.md b/src/AsyncAPI.Saunter.Generator.Cli/readme.md new file mode 100644 index 00000000..01030989 --- /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). + +## Tool usage +``` +dotnet asyncapi tofile [startup-assembly] --output [output-path] --format [json,yml,yaml] --doc [asyncapi-document-name] +``` +- _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 documents will be exported to (Default: the csproj root "./"). +- _--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 (Default: "json"). +- _--env_: define environment variable(s) for the application. Formatted as a comma separated list of _key=value_ pairs, example: ```ASPNETCORE_ENVIRONMENT=AsyncAPI,CONNECT_TO_DATABASE=false```. + +## 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) + +## Internals +How does the tool work internally? It tries to exact an ```IServiceProvider``` from the provided _startup-assembly_ and exports AsyncApiDocument(s) as registered with the services provider. \ No newline at end of file diff --git a/src/Saunter/AsyncApiOptions.cs b/src/Saunter/AsyncApiOptions.cs index cc64cc55..cdd410fb 100644 --- a/src/Saunter/AsyncApiOptions.cs +++ b/src/Saunter/AsyncApiOptions.cs @@ -1,13 +1,13 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; - +using System.Collections.Generic; + using Newtonsoft.Json; using Newtonsoft.Json.Serialization; - + using NJsonSchema; using NJsonSchema.NewtonsoftJson.Generation; - + using Saunter.AsyncApiSchema.v2; using Saunter.Generation.Filters; using Saunter.Generation.SchemaGeneration; @@ -90,10 +90,10 @@ public void AddOperationFilter() where T : IOperationFilter public class AsyncApiSchemaOptions : NewtonsoftJsonSchemaGeneratorSettings { - public AsyncApiSchemaOptions() + public AsyncApiSchemaOptions() { SchemaType = SchemaType.JsonSchema; // AsyncAPI uses json-schema, see https://github.com/tehmantra/saunter/pull/103#issuecomment-893267360 - TypeNameGenerator = new CamelCaseTypeNameGenerator(); + TypeNameGenerator = new CamelCaseTypeNameGenerator(); SerializerSettings = new JsonSerializerSettings() { ContractResolver = new CamelCasePropertyNamesContractResolver(), @@ -120,4 +120,4 @@ public class AsyncApiMiddlewareOptions /// public string UiTitle { get; set; } = "AsyncAPI"; } -} +} diff --git a/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs b/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs index ed38829a..a5604b64 100644 --- a/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs +++ b/src/Saunter/AsyncApiSchema/v2/AsyncApiDocument.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using System.Linq; - -using Newtonsoft.Json; - +using System.Linq; + +using Newtonsoft.Json; + using NJsonSchema.NewtonsoftJson.Converters; namespace Saunter.AsyncApiSchema.v2 @@ -15,8 +15,8 @@ public class AsyncApiDocument : ICloneable /// Specifies the AsyncAPI Specification version being used. /// [JsonProperty("asyncapi", NullValueHandling = NullValueHandling.Ignore)] - public string AsyncApi { get; } = "2.4.0"; - + public string AsyncApi { get; } = "2.4.0"; + /// /// Identifier of the application the AsyncAPI document is defining. /// @@ -105,4 +105,4 @@ object ICloneable.Clone() return Clone(); } } -} +} diff --git a/src/Saunter/AsyncApiServiceCollectionExtensions.cs b/src/Saunter/AsyncApiServiceCollectionExtensions.cs index 288001cb..cf2ac39c 100644 --- a/src/Saunter/AsyncApiServiceCollectionExtensions.cs +++ b/src/Saunter/AsyncApiServiceCollectionExtensions.cs @@ -1,8 +1,8 @@ -using System; - +using System; + using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - +using Microsoft.Extensions.DependencyInjection.Extensions; + using Saunter.AsyncApiSchema.v2; using Saunter.Generation; using Saunter.Serialization; @@ -57,4 +57,4 @@ public static IServiceCollection ConfigureNamedAsyncApi(this IServiceCollection return services; } } -} +} 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..edd24802 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/AsyncAPI.Saunter.Generator.Cli.Tests.csproj @@ -0,0 +1,44 @@ + + + + net8.0 + enable + + false + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/E2ETests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/E2ETests.cs new file mode 100644 index 00000000..0134ce62 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/E2ETests.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests; + +public class E2ETests(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(Skip = "Manual verification only")] + public void Pack_Install_Run_Uninstall_Test() + { + var workingDirectory = "../../../../../src/AsyncAPI.Saunter.Generator.Cli"; + var stdOut = this.Run("dotnet", "pack", workingDirectory); + stdOut.ShouldContain("Successfully created package"); + + // use --force flag to ensure the test starts clean every run + stdOut = this.Run("dotnet", "new tool-manifest --force", workingDirectory); + stdOut.ShouldContain("The template \"Dotnet local tool manifest file\" was created successfully"); + + stdOut = this.Run("dotnet", "tool install --local --add-source ./bin/Release AsyncAPI.Saunter.Generator.Cli", workingDirectory); + stdOut = stdOut.Replace("Skipping NuGet package signature verification.", "").Trim(); + stdOut.ShouldContain("You can invoke the tool from this directory using the following commands: 'dotnet tool run dotnet-asyncapi"); + stdOut.ShouldContain("was successfully installed."); + + stdOut = this.Run("dotnet", "tool list --local asyncapi.saunter.generator.cli", workingDirectory); + stdOut.ShouldContain("dotnet-asyncapi"); + + stdOut = this.Run("dotnet", "tool run dotnet-asyncapi", workingDirectory, 1); + stdOut.ShouldContain("tofile: retrieves AsyncAPI from a startup assembly, and writes to file"); + + stdOut = this.Run("dotnet", "tool uninstall --local asyncapi.saunter.generator.cli", workingDirectory); + stdOut.ShouldContain(" was successfully uninstalled"); + stdOut.ShouldContain("removed from manifest file"); + + stdOut = this.Run("dotnet", "tool list --local asyncapi.saunter.generator.cli", workingDirectory, 1); + stdOut.ShouldNotContain("dotnet-asyncapi"); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/IntegrationTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/IntegrationTests.cs new file mode 100644 index 00000000..c6079917 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/IntegrationTests.cs @@ -0,0 +1,85 @@ +using Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests; + +public class IntegrationTests(ITestOutputHelper output) +{ + private string RunTool(string args, int expectedExitCode = 0) + { + using var outWriter = new StringWriter(); + using var errorWriter = new StringWriter(); + Console.SetOut(outWriter); + Console.SetError(errorWriter); + + var entryPoint = typeof(Program).Assembly.EntryPoint!; + entryPoint.Invoke(null, new object[] { args.Split(' ') }); + + var stdOut = outWriter.ToString(); + var stdError = errorWriter.ToString(); + output.WriteLine($"RUN: {args}"); + output.WriteLine("### STD OUT"); + output.WriteLine(stdOut); + output.WriteLine("### STD ERROR"); + output.WriteLine(stdError); + + Environment.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("tofile").Trim(); + + stdOut.ShouldBe(""" + Usage: tofile [arguments...] [options...] [-h|--help] [--version] + + Retrieves AsyncAPI spec from a startup assembly and writes to file. + + Arguments: + [0] relative path to the application's startup assembly + + Options: + -o|--output relative path where the AsyncAPI documents will be exported to (Default: "./") + -d|--doc name(s) of the AsyncAPI documents you want to export as configured in your startup class. To export all documents using null. (Default: null) + --format exports AsyncAPI in json and/or yml format (Default: "json") + --filename defines the file name template, {document} and {extension} template variables can be used (Default: "{document}_asyncapi.{extension}") + --env define environment variable(s) for the application. Formatted as a comma separated list of _key=value_ pairs (Default: "") + """, StringCompareShould.IgnoreLineEndings); + } + + /// + /// Both example projects are used to check whether AsyncAPI spec generation is working because they are targeting different .NET versions and are using different hosting strategies. + /// - StreetlightsAPI project is targeting NET6 using the 'old school' Startup-class hosting mechanism. + /// - StreetlightsAPI.TopLevelStatement project is targeting NET8 using the new Top Level Statement hosting mechanism. + /// + [Theory] + [InlineData("StreetlightsAPI", "net6.0")] + [InlineData("StreetlightsAPI.TopLevelStatement", "net8.0")] + public void Streetlights_ExportSpecTest(string csprojName, string targetFramework) + { + var path = Path.Combine(Directory.GetCurrentDirectory()); + output.WriteLine($"Output path: {path}"); + var stdOut = RunTool($"tofile ../../../../../examples/{csprojName}/bin/Debug/{targetFramework}/{csprojName}.dll --output {path} --filename {csprojName}.{{extension}} --format json,yml,yaml"); + + stdOut.ShouldNotBeEmpty(); + stdOut.ShouldContain($"AsyncAPI yaml successfully written to {Path.Combine(path, $"{csprojName}.yaml")}"); + stdOut.ShouldContain($"AsyncAPI yml successfully written to {Path.Combine(path, $"{csprojName}.yml")}"); + stdOut.ShouldContain($"AsyncAPI json successfully written to {Path.Combine(path, $"{csprojName}.json")}"); + + File.Exists(Path.Combine(path, $"{csprojName}.yml")).ShouldBeTrue(); + File.Exists(Path.Combine(path, $"{csprojName}.yaml")).ShouldBeTrue(); + File.Exists(Path.Combine(path, $"{csprojName}.json")).ShouldBeTrue(); + + var yml = File.ReadAllText(Path.Combine(path, $"{csprojName}.yml")); + yml.ShouldBe(ExpectedSpecFiles.Yml_v2_6, "yml"); + + var yaml = File.ReadAllText(Path.Combine(path, $"{csprojName}.yaml")); + yaml.ShouldBe(yml, "yaml"); + + var json = File.ReadAllText(Path.Combine(path, $"{csprojName}.json")); + json.ShouldBe(ExpectedSpecFiles.Json_v2_6, "json"); + } +} 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..0d569e3a --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/Specs/ExpectedSpecFiles.cs @@ -0,0 +1,8 @@ +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 diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/AsyncApiDocumentExtractorTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/AsyncApiDocumentExtractorTests.cs new file mode 100644 index 00000000..bf091832 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/AsyncApiDocumentExtractorTests.cs @@ -0,0 +1,152 @@ +using AsyncAPI.Saunter.Generator.Cli.ToFile; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.Community.Logging; +using Saunter; +using Saunter.AsyncApiSchema.v2; +using Saunter.Serialization; +using Shouldly; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests.ToFile; + +public class AsyncApiDocumentExtractorTests +{ + private readonly AsyncApiDocumentExtractor _extractor; + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly IAsyncApiDocumentProvider _documentProvider; + private readonly IOptions _asyncApiOptions; + private readonly IAsyncApiDocumentSerializer _documentSerializer; + + public AsyncApiDocumentExtractorTests() + { + var services = new ServiceCollection(); + this._documentProvider = Substitute.For(); + this._asyncApiOptions = Substitute.For>(); + var options = new AsyncApiOptions(); + this._asyncApiOptions.Value.Returns(options); + this._documentSerializer = Substitute.For(); + services.AddSingleton(this._documentProvider); + services.AddSingleton(this._asyncApiOptions); + services.AddSingleton(this._documentSerializer); + this._serviceProvider = services.BuildServiceProvider(); + + this._logger = Substitute.For>(); + this._extractor = new AsyncApiDocumentExtractor(this._logger); + } + + [Fact] + public void GetAsyncApiDocument_Null_NoMarkerAssemblies() + { + var documents = this._extractor.GetAsyncApiDocument(this._serviceProvider, null).ToList(); + + this._logger.Received(1).CallToLog(LogLevel.Critical); + } + + [Fact] + public void GetAsyncApiDocument_Default_WithMarkerAssembly() + { + this._asyncApiOptions.Value.AssemblyMarkerTypes = [typeof(AsyncApiDocumentExtractorTests)]; + var doc = new AsyncApiDocument(); + this._documentProvider.GetDocument(default, default).ReturnsForAnyArgs(doc); + this._documentSerializer.Serialize(doc).ReturnsForAnyArgs(""" + asyncapi: 2.6.0 + info: + title: Streetlights API + """); + + var documents = this._extractor.GetAsyncApiDocument(this._serviceProvider, null).ToList(); + + this._logger.Received(0).CallToLog(LogLevel.Critical); + documents.Count.ShouldBe(1); + documents[0].name.ShouldBeNull(); + documents[0].document.Info.Title.ShouldBe("Streetlights API"); + } + + [Fact] + public void GetAsyncApiDocument_1NamedDocument() + { + this._asyncApiOptions.Value.AssemblyMarkerTypes = [typeof(AsyncApiDocumentExtractorTests)]; + var doc = new AsyncApiDocument(); + this._asyncApiOptions.Value.NamedApis["service 1"] = doc; + this._documentProvider.GetDocument(default, default).ReturnsForAnyArgs(doc); + this._documentSerializer.Serialize(doc).ReturnsForAnyArgs(""" + asyncapi: 2.6.0 + info: + title: Streetlights API + """); + + var documents = this._extractor.GetAsyncApiDocument(this._serviceProvider, null).ToList(); + + this._logger.Received(0).CallToLog(LogLevel.Critical); + documents.Count.ShouldBe(1); + documents[0].name.ShouldBe("service 1"); + documents[0].document.Info.Title.ShouldBe("Streetlights API"); + } + + [Fact] + public void GetAsyncApiDocument_2NamedDocument() + { + this._asyncApiOptions.Value.AssemblyMarkerTypes = [typeof(AsyncApiDocumentExtractorTests)]; + var doc1 = new AsyncApiDocument { Id = "1" }; + var doc2 = new AsyncApiDocument { Id = "2" }; + this._asyncApiOptions.Value.NamedApis["service 1"] = doc1; + this._asyncApiOptions.Value.NamedApis["service 2"] = doc2; + this._documentProvider.GetDocument(Arg.Any(), Arg.Is(doc1)).Returns(doc1); + this._documentProvider.GetDocument(Arg.Any(), Arg.Is(doc2)).Returns(doc2); + this._documentSerializer.Serialize(doc1).Returns(""" + asyncapi: 2.6.0 + info: + title: Streetlights API 1 + """); + this._documentSerializer.Serialize(doc2).Returns(""" + asyncapi: 2.6.0 + info: + title: Streetlights API 2 + """); + + var documents = this._extractor.GetAsyncApiDocument(this._serviceProvider, null).OrderBy(x => x.name).ToList(); + + this._logger.Received(0).CallToLog(LogLevel.Critical); + documents.Count.ShouldBe(2); + documents[0].name.ShouldBe("service 1"); + documents[0].document.Info.Title.ShouldBe("Streetlights API 1"); + documents[1].name.ShouldBe("service 2"); + documents[1].document.Info.Title.ShouldBe("Streetlights API 2"); + } + + [Fact] + public void GetAsyncApiDocument_LogErrors() + { + this._asyncApiOptions.Value.AssemblyMarkerTypes = [typeof(AsyncApiDocumentExtractorTests)]; + var doc = new AsyncApiDocument(); + this._documentProvider.GetDocument(default, default).ReturnsForAnyArgs(doc); + this._documentSerializer.Serialize(doc).ReturnsForAnyArgs(""" + asyncapi: 2.6.0 + info: + title: Streetlights API + 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' + """); + + var documents = this._extractor.GetAsyncApiDocument(this._serviceProvider, null).ToList(); + + this._logger.Received(0).CallToLog(LogLevel.Critical); + this._logger.Received(3).CallToLog(LogLevel.Error); + this._logger.Received(0).CallToLog(LogLevel.Warning); + documents.Count.ShouldBe(1); + documents[0].name.ShouldBeNull(); + documents[0].document.Info.Title.ShouldBe("Streetlights API"); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/EnvironmentBuilderTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/EnvironmentBuilderTests.cs new file mode 100644 index 00000000..de26811d --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/EnvironmentBuilderTests.cs @@ -0,0 +1,94 @@ +using System.Collections; +using AsyncAPI.Saunter.Generator.Cli.ToFile; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.Community.Logging; +using Shouldly; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests.ToFile; + +public class EnvironmentBuilderTests : IDisposable +{ + private readonly IDictionary _variablesBefore = Environment.GetEnvironmentVariables(EnvironmentVariableTarget.Process); + private readonly EnvironmentBuilder _environment; + private readonly ILogger _logger; + + public EnvironmentBuilderTests() + { + this._logger = Substitute.For>(); + this._environment = new EnvironmentBuilder(this._logger); + } + + private Dictionary GetAddedEnvironmentVariables() + { + var after = Environment.GetEnvironmentVariables(EnvironmentVariableTarget.Process); + return after.Cast().ExceptBy(this._variablesBefore.Keys.Cast(), x => x.Key).ToDictionary(x => x.Key.ToString(), x => x.Value?.ToString()); + } + + public void Dispose() + { + foreach (var variable in this.GetAddedEnvironmentVariables()) + { + Environment.SetEnvironmentVariable(variable.Key, null, EnvironmentVariableTarget.Process); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyEnvStringProvided(string env) + { + this._environment.SetEnvironmentVariables(env); + + this._logger.ReceivedCalls().Count().ShouldBe(0); + this.GetAddedEnvironmentVariables().ShouldBeEmpty(); + } + + [Theory] + [InlineData("env1=val1", 1)] + [InlineData("a=b,b=c", 2)] + public void ValidEnvStringProvided(string env, int expectedSets) + { + this._environment.SetEnvironmentVariables(env); + + this._logger.Received(expectedSets).CallToLog(LogLevel.Debug); + this.GetAddedEnvironmentVariables().ShouldNotBeEmpty(); + } + + [Theory] + [InlineData(",", 2)] + [InlineData(",,,,", 5)] + [InlineData("=a", 1)] + [InlineData("b", 1)] + [InlineData("=", 1)] + [InlineData("====", 1)] + public void InvalidEnvStringProvided(string env, int expectedSets) + { + this._environment.SetEnvironmentVariables(env); + + this._logger.Received(expectedSets).CallToLog(LogLevel.Critical); + this.GetAddedEnvironmentVariables().ShouldBeEmpty(); + } + + [Fact] + public void ValidateEnvValues() + { + this._environment.SetEnvironmentVariables("ENV=1,,Test=two"); + + Environment.GetEnvironmentVariable("ENV").ShouldBe("1"); + Environment.GetEnvironmentVariable("Test").ShouldBe("two"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void EmptyValueDeletesEnvValue(string value) + { + this._environment.SetEnvironmentVariables($"ENV=1,,ENV={value}"); + + Environment.GetEnvironmentVariable("ENV").ShouldBe(null); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/FileWriterTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/FileWriterTests.cs new file mode 100644 index 00000000..129959b0 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/FileWriterTests.cs @@ -0,0 +1,107 @@ +using System.Text; +using AsyncAPI.Saunter.Generator.Cli.ToFile; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests.ToFile; + +public class FileWriterTests +{ + private readonly Action _testContextWriter = stream => stream.Write(Encoding.Default.GetBytes("ananas")); + + private readonly FileWriter _writer; + private readonly IStreamProvider _streamProvider; + private readonly ILogger _logger; + private readonly MemoryStream _stream = new(); + + public FileWriterTests(ITestOutputHelper output) + { + this._logger = Substitute.For>(); + this._streamProvider = Substitute.For(); + this._streamProvider.GetStreamFor(default).ReturnsForAnyArgs(x => + { + output.WriteLine($"GetStreamFor({x.Args()[0]})"); + return this._stream; + }); + this._writer = new FileWriter(this._streamProvider, this._logger); + } + + [Fact] + public void CheckStreamContents() + { + this._writer.Write("/", "", "", "", _testContextWriter); + + this._streamProvider.Received(1).GetStreamFor(Path.GetFullPath("/")); + Encoding.Default.GetString(this._stream.GetBuffer().Take(6).ToArray()).ShouldBe("ananas"); + } + + [Fact] + public void CheckName_NoVariablesInTemplate() + { + this._writer.Write("/some/path", "fixed_name", "doc", "json", _testContextWriter); + + this._streamProvider.Received(1).GetStreamFor(Path.GetFullPath("/some/path/fixed_name")); + } + + [Theory] + [InlineData("./")] + [InlineData("/")] + [InlineData("/test/")] + [InlineData("/test/1/2/3/4/")] + public void CheckOutputPath_BaseOutputPath_Absolute(string path) + { + this._writer.Write(path, "document.something", "", "", _testContextWriter); + + this._streamProvider.Received(1).GetStreamFor(Path.GetFullPath($"{path}document.something")); + } + + [Theory] + [InlineData(".")] + [InlineData("")] + [InlineData("asyncApi/")] + [InlineData("service-1/")] + [InlineData("service 1/")] + [InlineData("service 1/spec")] + public void CheckOutputPath_BaseOutputPath_Relative(string path) + { + this._writer.Write(path, "document.something", "", "", _testContextWriter); + + this._streamProvider.Received(1).GetStreamFor(Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), path, "document.something"))); + } + + [Theory] + [InlineData("json")] + [InlineData("yml")] + [InlineData("txt")] + public void CheckOutputPath_FormatTemplate(string format) + { + this._writer.Write("/some/path", "{extension}_name.{extension}", "doc", format, _testContextWriter); + + this._streamProvider.Received(1).GetStreamFor(Path.GetFullPath($"/some/path/{format}_name.{format}")); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void CheckOutputPath_FormatTemplate_trimmed(string format) + { + this._writer.Write("/some/path", "{extension}_name.{extension}", "doc", format, _testContextWriter); + + this._streamProvider.Received(1).GetStreamFor(Path.GetFullPath("/some/path/name.")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("asyncApi")] + [InlineData("service-1")] + [InlineData("service 1")] + public void CheckOutputPath_DocumentNameTemplate(string documentName) + { + this._writer.Write("/some/path", "{document}.something", documentName, "", _testContextWriter); + + this._streamProvider.Received(1).GetStreamFor(Path.GetFullPath($"/some/path/{documentName}.something")); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/StreamProviderTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/StreamProviderTests.cs new file mode 100644 index 00000000..27051eb1 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/StreamProviderTests.cs @@ -0,0 +1,50 @@ +using AsyncAPI.Saunter.Generator.Cli.ToFile; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.Community.Logging; +using Shouldly; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests.ToFile; + +public class StreamProviderTests +{ + private readonly IStreamProvider _streamProvider; + private readonly ILogger _logger; + + public StreamProviderTests() + { + this._logger = Substitute.For>(); + this._streamProvider = new StreamProvider(this._logger); + } + + [Fact] + public void NullPathIsStdOut() + { + using var stream = this._streamProvider.GetStreamFor(null); + + stream.ShouldNotBeNull(); + Assert.False(stream is FileStream); + this._logger.Received(1).CallToLog(LogLevel.Debug); + } + + [Fact] + public void StringPathIsFileStream() + { + var path = Path.GetFullPath("./test"); + File.Delete(path); + try + { + using var stream = this._streamProvider.GetStreamFor(path); + + stream.ShouldNotBeNull(); + Assert.True(stream is FileStream); + File.Exists(path); + } + finally + { + File.Delete(path); + } + + this._logger.Received(1).CallToLog(LogLevel.Debug); + } +} diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/ToFileCommandTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/ToFileCommandTests.cs new file mode 100644 index 00000000..44e3c1f3 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/ToFile/ToFileCommandTests.cs @@ -0,0 +1,205 @@ +using AsyncAPI.Saunter.Generator.Cli.ToFile; +using LEGO.AsyncAPI.Models; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests.ToFile; + +public class ToFileCommandTests +{ + private readonly ToFileCommand _command; + private readonly IEnvironmentBuilder _environment; + private readonly IServiceProviderBuilder _builder; + private readonly IAsyncApiDocumentExtractor _docExtractor; + private readonly IFileWriter _fileWriter; + private readonly ILogger _logger; + private readonly ITestOutputHelper _output; + + public ToFileCommandTests(ITestOutputHelper output) + { + this._output = output; + this._logger = Substitute.For>(); + this._environment = Substitute.For(); + this._builder = Substitute.For(); + this._docExtractor = Substitute.For(); + this._fileWriter = Substitute.For(); + this._command = new ToFileCommand(this._logger, _environment, _builder, _docExtractor, _fileWriter); + } + + [Fact] + public void StartupAssembly_FileNotFoundException() + { + Assert.Throws(() => this._command.ToFile("")); + } + + [Fact] + public void SetEnvironmentVariables() + { + var me = typeof(ToFileCommandTests).Assembly.Location; + + this._command.ToFile(me, env: "env=value"); + + this._environment.Received(1).SetEnvironmentVariables("env=value"); + } + + [Fact] + public void BuildServiceProvider() + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + + this._command.ToFile(me); + + this._builder.Received(1).BuildServiceProvider(me); + } + + [Fact] + public void GetAsyncApiDocument_DefaultDocParam() + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + var sp = Substitute.For(); + this._builder.BuildServiceProvider(default).ReturnsForAnyArgs(sp); + + this._command.ToFile(me); + + this._docExtractor.Received(1).GetAsyncApiDocument(sp, null); + } + + [Fact] + public void GetAsyncApiDocument_DocParam() + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + var sp = Substitute.For(); + this._builder.BuildServiceProvider(default).ReturnsForAnyArgs(sp); + + this._command.ToFile(me, doc: "a"); + + this._docExtractor.Received(1).GetAsyncApiDocument(sp, Arg.Is(x => x.SequenceEqual(new[] { "a" }))); ; + } + + [Fact] + public void GetAsyncApiDocument_DocParamMultiple() + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + var sp = Substitute.For(); + this._builder.BuildServiceProvider(default).ReturnsForAnyArgs(sp); + + this._command.ToFile(me, doc: "a,b, c ,,"); + + this._docExtractor.Received(1).GetAsyncApiDocument(sp, Arg.Is(x => x.SequenceEqual(new[] { "a", "b", " c " }))); + } + + [Fact] + public void WriteFile_DefaultParams() + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + this._docExtractor.GetAsyncApiDocument(default, default).ReturnsForAnyArgs([(null, new AsyncApiDocument { Info = new AsyncApiInfo { Title = "a" } })]); + + this._command.ToFile(me); + + this._fileWriter.ReceivedCalls().Count().ShouldBe(1); + this._fileWriter.Received(1).Write(Path.GetFullPath("./"), "{document}_asyncapi.{extension}", null, "json", Arg.Any>()); + } + + [Theory] + [InlineData("json")] + [InlineData("yml")] + [InlineData("yaml")] + public void WriteFile_FormatParam(string format) + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + this._docExtractor.GetAsyncApiDocument(default, default).ReturnsForAnyArgs([(null, new AsyncApiDocument { Info = new AsyncApiInfo { Title = "a" } })]); + + this._command.ToFile(me, format: format); + + this._fileWriter.ReceivedCalls().Count().ShouldBe(1); + this._fileWriter.Received(1).Write(Path.GetFullPath("./"), "{document}_asyncapi.{extension}", null, format, Arg.Any>()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void WriteFile_EmptyFormatParamVariants_FallbackToJson(string format) + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + this._docExtractor.GetAsyncApiDocument(default, default).ReturnsForAnyArgs([(null, new AsyncApiDocument { Info = new AsyncApiInfo { Title = "a" } })]); + + this._command.ToFile(me, format: format); + + this._fileWriter.ReceivedCalls().Count().ShouldBe(1); + this._fileWriter.Received(1).Write(Path.GetFullPath("./"), "{document}_asyncapi.{extension}", null, "json", Arg.Any>()); + } + + [Theory] + [InlineData("a")] + [InlineData("json1")] + [InlineData(".json")] + public void WriteFile_InvalidFormatParam_FallbackToJson(string format) + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + this._docExtractor.GetAsyncApiDocument(default, default).ReturnsForAnyArgs([(null, new AsyncApiDocument { Info = new AsyncApiInfo { Title = "a" } })]); + + this._command.ToFile(me, format: format); + + this._fileWriter.ReceivedCalls().Count().ShouldBe(0); + } + + [Fact] + public void WriteFile_FormatParamMultiple() + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + this._docExtractor.GetAsyncApiDocument(default, default).ReturnsForAnyArgs([(null, new AsyncApiDocument { Info = new AsyncApiInfo { Title = "a" } })]); + + this._command.ToFile(me, format: " json , yaml,yml ,,a, "); + + this._fileWriter.ReceivedCalls().Count().ShouldBe(3); + this._fileWriter.Received(1).Write(Path.GetFullPath("./"), "{document}_asyncapi.{extension}", null, "json", Arg.Any>()); + this._fileWriter.Received(1).Write(Path.GetFullPath("./"), "{document}_asyncapi.{extension}", null, "yml", Arg.Any>()); + this._fileWriter.Received(1).Write(Path.GetFullPath("./"), "{document}_asyncapi.{extension}", null, "yaml", Arg.Any>()); + } + + [Theory] + [InlineData("doc")] + [InlineData("{document}")] + [InlineData("{extension}")] + [InlineData("{document}.{extension}")] + public void WriteFile_FileTemplateParam(string template) + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + this._docExtractor.GetAsyncApiDocument(default, default).ReturnsForAnyArgs([(null, new AsyncApiDocument { Info = new AsyncApiInfo { Title = "a" } })]); + + this._command.ToFile(me, filename: template); + + this._fileWriter.ReceivedCalls().Count().ShouldBe(1); + this._fileWriter.Received(1).Write(Path.GetFullPath("./"), template, null, "json", Arg.Any>()); + } + + [Theory] + [InlineData("./")] + [InlineData("/")] + [InlineData("a/")] + [InlineData("/a/b")] + public void WriteFile_OutputPathParam(string path) + { + var me = typeof(ToFileCommandTests).Assembly.Location; + this._output.WriteLine($"Assembly: {me}"); + this._docExtractor.GetAsyncApiDocument(default, default).ReturnsForAnyArgs([(null, new AsyncApiDocument { Info = new AsyncApiInfo { Title = "a" } })]); + + this._command.ToFile(me, output: path); + + this._fileWriter.ReceivedCalls().Count().ShouldBe(1); + this._fileWriter.Received(1).Write(Path.GetFullPath(path), "{document}_asyncapi.{extension}", null, "json", Arg.Any>()); + } +} diff --git a/test/Saunter.Tests.MarkerTypeTests/Saunter.Tests.MarkerTypeTests.csproj b/test/Saunter.Tests.MarkerTypeTests/Saunter.Tests.MarkerTypeTests.csproj index 014abc3c..bd04796d 100644 --- a/test/Saunter.Tests.MarkerTypeTests/Saunter.Tests.MarkerTypeTests.csproj +++ b/test/Saunter.Tests.MarkerTypeTests/Saunter.Tests.MarkerTypeTests.csproj @@ -4,6 +4,7 @@ net6.0 enable enable + false diff --git a/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs b/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs index a5183890..030dbfd4 100644 --- a/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs +++ b/test/Saunter.Tests/Generation/DocumentGeneratorTests/InterfaceAttributeTests.cs @@ -1,107 +1,103 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Reflection; -using Saunter.AsyncApiSchema.v2; -using Saunter.Attributes; -using Saunter.Generation; -using Shouldly; -using Xunit; -using System.Linq; - -namespace Saunter.Tests.Generation.DocumentGeneratorTests -{ - public class InterfaceAttributeTests - { - [Theory] - [InlineData(typeof(IServiceEvents))] - [InlineData(typeof(ServiceEventsFromInterface))] - [InlineData(typeof(ServiceEventsFromAnnotatedInterface))] // Check that annotations are not inherited from the interface - public void NonAnnotatedTypesTest(Type type) - { - // Arrange - var options = new AsyncApiOptions(); - var documentGenerator = new DocumentGenerator(); - - // Act - var document = documentGenerator.GenerateDocument(new[] { type.GetTypeInfo() }, options, options.AsyncApi, ActivatorServiceProvider.Instance); - - // Assert - document.ShouldNotBeNull(); - document.Channels.Count.ShouldBe(0); - } - - [Theory] - [InlineData(typeof(IAnnotatedServiceEvents), "interface")] - [InlineData(typeof(AnnotatedServiceEventsFromInterface), "class")] - [InlineData(typeof(AnnotatedServiceEventsFromAnnotatedInterface), "class")] // Check that the actual type's annotation takes precedence of the inherited interface - public void AnnotatedTypesTest(Type type, string source) - { - // Arrange - var options = new AsyncApiOptions(); - var documentGenerator = new DocumentGenerator(); - - // Act - var document = documentGenerator.GenerateDocument(new[] { type.GetTypeInfo() }, options, options.AsyncApi, ActivatorServiceProvider.Instance); - - // Assert - document.ShouldNotBeNull(); - document.Channels.Count.ShouldBe(1); - - var channel = document.Channels.First(); - channel.Key.ShouldBe($"{source}.event"); - channel.Value.Description.ShouldBeNull(); - - var publish = channel.Value.Publish; - publish.ShouldNotBeNull(); - publish.OperationId.ShouldBe("PublishEvent"); - publish.Description.ShouldBe($"({source}) Subscribe to domains events about a tenant."); - - var messageRef = publish.Message.ShouldBeOfType(); - messageRef.Id.ShouldBe("tenantEvent"); - } - - [AsyncApi] - private interface IAnnotatedServiceEvents - { - [Channel("interface.event")] - [PublishOperation(typeof(TenantEvent), Description = "(interface) Subscribe to domains events about a tenant.")] - void PublishEvent(TenantEvent evt); - } - - private interface IServiceEvents - { - void PublishEvent(TenantEvent evt); - } - - private class ServiceEventsFromInterface : IServiceEvents - { - public void PublishEvent(TenantEvent evt) { } - } - - private class ServiceEventsFromAnnotatedInterface : IAnnotatedServiceEvents - { - public void PublishEvent(TenantEvent evt) { } - } - - [AsyncApi] - private class AnnotatedServiceEventsFromInterface : IAnnotatedServiceEvents - { - [Channel("class.event")] - [PublishOperation(typeof(TenantEvent), Description = "(class) Subscribe to domains events about a tenant.")] - public void PublishEvent(TenantEvent evt) { } - } - - [AsyncApi] - private class AnnotatedServiceEventsFromAnnotatedInterface : IAnnotatedServiceEvents - { - [Channel("class.event")] - [PublishOperation(typeof(TenantEvent), Description = "(class) Subscribe to domains events about a tenant.")] - public void PublishEvent(TenantEvent evt) { } - } - - private class TenantEvent { } - } -} +using System; +using System.Linq; +using System.Reflection; +using Saunter.AsyncApiSchema.v2; +using Saunter.Attributes; +using Saunter.Generation; +using Shouldly; +using Xunit; + +namespace Saunter.Tests.Generation.DocumentGeneratorTests +{ + public class InterfaceAttributeTests + { + [Theory] + [InlineData(typeof(IServiceEvents))] + [InlineData(typeof(ServiceEventsFromInterface))] + [InlineData(typeof(ServiceEventsFromAnnotatedInterface))] // Check that annotations are not inherited from the interface + public void NonAnnotatedTypesTest(Type type) + { + // Arrange + var options = new AsyncApiOptions(); + var documentGenerator = new DocumentGenerator(); + + // Act + var document = documentGenerator.GenerateDocument(new[] { type.GetTypeInfo() }, options, options.AsyncApi, ActivatorServiceProvider.Instance); + + // Assert + document.ShouldNotBeNull(); + document.Channels.Count.ShouldBe(0); + } + + [Theory] + [InlineData(typeof(IAnnotatedServiceEvents), "interface")] + [InlineData(typeof(AnnotatedServiceEventsFromInterface), "class")] + [InlineData(typeof(AnnotatedServiceEventsFromAnnotatedInterface), "class")] // Check that the actual type's annotation takes precedence of the inherited interface + public void AnnotatedTypesTest(Type type, string source) + { + // Arrange + var options = new AsyncApiOptions(); + var documentGenerator = new DocumentGenerator(); + + // Act + var document = documentGenerator.GenerateDocument(new[] { type.GetTypeInfo() }, options, options.AsyncApi, ActivatorServiceProvider.Instance); + + // Assert + document.ShouldNotBeNull(); + document.Channels.Count.ShouldBe(1); + + var channel = document.Channels.First(); + channel.Key.ShouldBe($"{source}.event"); + channel.Value.Description.ShouldBeNull(); + + var publish = channel.Value.Publish; + publish.ShouldNotBeNull(); + publish.OperationId.ShouldBe("PublishEvent"); + publish.Description.ShouldBe($"({source}) Subscribe to domains events about a tenant."); + + var messageRef = publish.Message.ShouldBeOfType(); + messageRef.Id.ShouldBe("tenantEvent"); + } + + [AsyncApi] + private interface IAnnotatedServiceEvents + { + [Channel("interface.event")] + [PublishOperation(typeof(TenantEvent), Description = "(interface) Subscribe to domains events about a tenant.")] + void PublishEvent(TenantEvent evt); + } + + private interface IServiceEvents + { + void PublishEvent(TenantEvent evt); + } + + private class ServiceEventsFromInterface : IServiceEvents + { + public void PublishEvent(TenantEvent evt) { } + } + + private class ServiceEventsFromAnnotatedInterface : IAnnotatedServiceEvents + { + public void PublishEvent(TenantEvent evt) { } + } + + [AsyncApi] + private class AnnotatedServiceEventsFromInterface : IAnnotatedServiceEvents + { + [Channel("class.event")] + [PublishOperation(typeof(TenantEvent), Description = "(class) Subscribe to domains events about a tenant.")] + public void PublishEvent(TenantEvent evt) { } + } + + [AsyncApi] + private class AnnotatedServiceEventsFromAnnotatedInterface : IAnnotatedServiceEvents + { + [Channel("class.event")] + [PublishOperation(typeof(TenantEvent), Description = "(class) Subscribe to domains events about a tenant.")] + public void PublishEvent(TenantEvent evt) { } + } + + private class TenantEvent { } + } +} diff --git a/test/Saunter.Tests/Generation/DocumentProviderTests/AsyncApiTypesTests.cs b/test/Saunter.Tests/Generation/DocumentProviderTests/AsyncApiTypesTests.cs index c3692640..c7806ae5 100644 --- a/test/Saunter.Tests/Generation/DocumentProviderTests/AsyncApiTypesTests.cs +++ b/test/Saunter.Tests/Generation/DocumentProviderTests/AsyncApiTypesTests.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Saunter.AsyncApiSchema.v2; -using Saunter.Attributes; using Saunter.Tests.MarkerTypeTests; using Shouldly; using Xunit; diff --git a/test/Saunter.Tests/Generation/SchemaGeneration/SchemaGenerationTests.cs b/test/Saunter.Tests/Generation/SchemaGeneration/SchemaGenerationTests.cs index 41d11a8c..b836fa00 100644 --- a/test/Saunter.Tests/Generation/SchemaGeneration/SchemaGenerationTests.cs +++ b/test/Saunter.Tests/Generation/SchemaGeneration/SchemaGenerationTests.cs @@ -1,23 +1,23 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Runtime.Serialization; - +using System.Runtime.Serialization; + using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - +using Newtonsoft.Json.Serialization; + using NJsonSchema; using NJsonSchema.Generation; -using NJsonSchema.NewtonsoftJson.Converters; - +using NJsonSchema.NewtonsoftJson.Converters; + using Saunter.AsyncApiSchema.v2; using Saunter.Generation.SchemaGeneration; -using Saunter.Tests.Utils; - -using Shouldly; - -using Xunit; - +using Saunter.Tests.Utils; + +using Shouldly; + +using Xunit; + using JsonInheritanceAttribute = NJsonSchema.NewtonsoftJson.Converters.JsonInheritanceAttribute; namespace Saunter.Tests.Generation.SchemaGeneration @@ -97,8 +97,8 @@ public void GenerateSchema_GenerateSchemaFromClassWithDiscriminator_GeneratesSch var schema = _schemaGenerator.Generate(type, _schemaResolver); - schema.ShouldNotBeNull(); - + schema.ShouldNotBeNull(); + _schemaResolver.Schemas.ShouldNotBeNull(); var petSchema = _schemaResolver.Schemas.FirstOrDefault(s => s.Id == "pet"); petSchema.Discriminator.ShouldBe("petType"); @@ -242,4 +242,4 @@ public class Dog : Pet { public string PackSize { get; set; } } -} +} diff --git a/test/Saunter.Tests/Saunter.Tests.csproj b/test/Saunter.Tests/Saunter.Tests.csproj index 8f47b9f1..98b03563 100644 --- a/test/Saunter.Tests/Saunter.Tests.csproj +++ b/test/Saunter.Tests/Saunter.Tests.csproj @@ -17,11 +17,11 @@ - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive