diff --git a/Saunter.sln b/Saunter.sln index 2aff09fa..43967dd8 100644 --- a/Saunter.sln +++ b/Saunter.sln @@ -29,7 +29,9 @@ 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}" +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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AsyncAPI.Saunter.Generator.Cli.Tests", "test\AsyncAPI.Saunter.Generator.Cli.Tests\AsyncAPI.Saunter.Generator.Cli.Tests.csproj", "{18AD0249-0436-4A26-9972-B97BA6905A54}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -113,6 +115,18 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -124,6 +138,7 @@ Global {7CD09B89-130A-41AF-ADAE-2166C4ED695B} = {6491E321-2D02-44AB-9116-D722FE169595} {02284473-6DE7-4EE0-8433-2AC295045549} = {6491E321-2D02-44AB-9116-D722FE169595} {6C102D4D-3DA4-4763-B75E-C15E33E7E94A} = {28D4C365-FDED-49AE-A97D-36202E24A55A} + {18AD0249-0436-4A26-9972-B97BA6905A54} = {6491E321-2D02-44AB-9116-D722FE169595} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2F85D9DA-DBCF-4F13-8C42-5719F1469B2E} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs b/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs index 00ea898f..a3477c7a 100644 --- a/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs +++ b/src/AsyncAPI.Saunter.Generator.Cli/Commands/TofileInternal.cs @@ -15,12 +15,16 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore; using Microsoft.Extensions.Hosting; +using Saunter.AsyncApiSchema.v2; using static Program; +using AsyncApiDocument = Saunter.AsyncApiSchema.v2.AsyncApiDocument; namespace AsyncApi.Saunter.Generator.Cli.Commands; internal class TofileInternal { + private const string defaultDocumentName = null; + internal static int Run(IDictionary namedArgs) { // 1) Configure host with provided startupassembly @@ -43,24 +47,41 @@ internal static int Run(IDictionary namedArgs) // 3) Retrieve AsyncAPI via configured provider var documentProvider = serviceProvider.GetService(); - var asyncapiOptions = serviceProvider.GetService>(); + var asyncapiOptions = serviceProvider.GetService>().Value; var documentSerializer = serviceProvider.GetRequiredService(); - var documentNames = namedArgs.TryGetValue(DocOption, out var doc) ? [doc] : asyncapiOptions.Value.NamedApis.Keys; + var documentNames = namedArgs.TryGetValue(DocOption, out var doc) ? [doc] : asyncapiOptions.NamedApis.Keys; var fileTemplate = namedArgs.TryGetValue(FileNameOption, out var template) ? template : "{document}_asyncapi.{extension}"; + if (documentNames.Count == 0) + { + if (asyncapiOptions.AssemblyMarkerTypes.Any()) + { + documentNames = [defaultDocumentName]; + } + else + { + throw new ArgumentOutOfRangeException(DocOption, $"No AsyncAPI documents found: {DocOption} = '{doc}'. Known document(s): {string.Join(", ", asyncapiOptions.NamedApis.Keys)}."); + } + } + foreach (var documentName in documentNames) { - if (!asyncapiOptions.Value.NamedApis.TryGetValue(documentName, out var prototype)) + AsyncApiDocument prototype; + if (documentName == defaultDocumentName) + { + prototype = asyncapiOptions.AsyncApi; + } + else if (!asyncapiOptions.NamedApis.TryGetValue(documentName, out prototype)) { - throw new ArgumentOutOfRangeException(DocOption, documentName, $"Requested AsyncAPI document not found: '{documentName}'. Known document(s): {string.Join(", ", asyncapiOptions.Value.NamedApis.Keys)}."); + throw new ArgumentOutOfRangeException(DocOption, documentName, $"Requested AsyncAPI document not found: '{documentName}'. Known document(s): {string.Join(", ", asyncapiOptions.NamedApis.Keys)}."); } - var asyncApiSchema = documentProvider.GetDocument(asyncapiOptions.Value, prototype); - var asyncApiSchemaJson = documentSerializer.Serialize(asyncApiSchema); + var schema = documentProvider.GetDocument(asyncapiOptions, prototype); + var asyncApiSchemaJson = documentSerializer.Serialize(schema); var asyncApiDocument = new AsyncApiStringReader().Read(asyncApiSchemaJson, out var diagnostic); if (diagnostic.Errors.Any()) { - Console.Error.WriteLine($"AsyncAPI Schema '{documentName}' is not valid ({diagnostic.Errors.Count} Error(s), {diagnostic.Warnings.Count} Warning(s)):" + + Console.Error.WriteLine($"AsyncAPI Schema '{documentName ?? "default"}' is not valid ({diagnostic.Errors.Count} Error(s), {diagnostic.Warnings.Count} Warning(s)):" + $"{Environment.NewLine}{string.Join(Environment.NewLine, diagnostic.Errors.Select(x => $"- {x}"))}"); } @@ -123,7 +144,7 @@ private static string AddFileExtension(string outputPath, string fileTemplate, s return outputPath; } - return Path.Combine(outputPath, fileTemplate.Replace("{document}", documentName).Replace("{extension}", extension)); + return Path.Combine(outputPath, fileTemplate.Replace("{document}", documentName == defaultDocumentName ? "" : documentName).Replace("{extension}", extension).TrimStart('_')); } private static IServiceProvider GetServiceProvider(Assembly startupAssembly) diff --git a/src/AsyncAPI.Saunter.Generator.Cli/readme.md b/src/AsyncAPI.Saunter.Generator.Cli/readme.md index 6c73c716..11d3c9b4 100644 --- a/src/AsyncAPI.Saunter.Generator.Cli/readme.md +++ b/src/AsyncAPI.Saunter.Generator.Cli/readme.md @@ -8,8 +8,8 @@ dotnet asyncapi.net tofile --output [output-path] --format [json,yml,yaml] --doc startup-assembly: the file path to the entrypoint dotnet 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 not specified, all documents will be exported. ---output: relative path where the AsyncAPI will be output [defaults to stdout] ---filename: the template for the outputted file names. Default: "{document}_asyncapi.{extension}" ---format: the output formats to generate, can be a combination of json, yml and/or yaml. File extension is appended to the output path. ---env: define environment variable(s) for the application +--doc: The name of the AsyncAPI document as defined in the startup class by the ```.ConfigureNamedAsyncApi()```-method. If not specified, all documents will be exported. +--output: relative path where the AsyncAPI will be output [defaults to stdout] +--filename: the template for the outputted file names. Default: "{document}_asyncapi.{extension}" +--format: the output formats to generate, can be a combination of json, yml and/or yaml. File extension is appended to the output path. +--env: define environment variable(s) for the application 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..8d3e969c --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/AsyncAPI.Saunter.Generator.Cli.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs b/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs new file mode 100644 index 00000000..3d6c7324 --- /dev/null +++ b/test/AsyncAPI.Saunter.Generator.Cli.Tests/DotnetCliToolTests.cs @@ -0,0 +1,234 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Shouldly; +using Xunit.Abstractions; + +namespace AsyncAPI.Saunter.Generator.Cli.Tests; + +public class DotnetCliToolTests(ITestOutputHelper output) +{ + private string RunTool(string args, int expectedExitCode = 0) + { + var process = Process.Start(new ProcessStartInfo("dotnet") + { + Arguments = $"../../../../../src/AsyncAPI.Saunter.Generator.Cli/bin/Debug/net6.0/AsyncAPI.Saunter.Generator.Cli.dll tofile {args}", + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + process.WaitForExit(); + var stdOut = process.StandardOutput.ReadToEnd().Trim(); + var stdError = process.StandardError.ReadToEnd().Trim(); + output.WriteLine(stdOut); + output.WriteLine(stdError); + + process.ExitCode.ShouldBe(expectedExitCode); + //stdError.ShouldBeEmpty(); LEGO lib doesn't like id: "id is not a valid property at #/components/schemas/lightMeasuredEvent"" + return stdOut; + } + + [Fact] + public void DefaultCallPrintsCommandInfo() + { + var stdOut = RunTool("", 1); + + stdOut.ShouldBe(""" + Usage: dotnet asyncapi.net tofile [options] [startupassembly] + + startupassembly: + relative path to the application's startup assembly + + options: + --doc: name(s) of the AsyncAPI documents you want to retrieve, as configured in your startup class [defaults to all documents] + --output: relative path where the AsyncAPI will be output [defaults to stdout] + --filename: defines the file name template, {document} and {extension} template variables can be used [defaults to "{document}_asyncapi.{extension}"] + --format: exports AsyncAPI in json and/or yml format [defaults to json] + --env: define environment variable(s) for the application during generation of the AsyncAPI files [defaults to empty, can be used to define for example ASPNETCORE_ENVIRONMENT] + """, StringCompareShould.IgnoreLineEndings); + } + + [Fact] + public void StreetlightsAPIExportSpecTest() + { + var path = Directory.GetCurrentDirectory(); + output.WriteLine($"Output path: {path}"); + var stdOut = RunTool($"--output {path} --format json,yml,yaml ../../../../../examples/StreetlightsAPI/bin/Debug/net6.0/StreetlightsAPI.dll"); + + stdOut.ShouldNotBeEmpty(); + stdOut.ShouldContain($"AsyncAPI yaml successfully written to {Path.Combine(path, "asyncapi.yaml")}"); + stdOut.ShouldContain($"AsyncAPI yml successfully written to {Path.Combine(path, "asyncapi.yml")}"); + stdOut.ShouldContain($"AsyncAPI json successfully written to {Path.Combine(path, "asyncapi.json")}"); + + File.Exists("asyncapi.yml").ShouldBeTrue("asyncapi.yml"); + File.Exists("asyncapi.yaml").ShouldBeTrue("asyncapi.yaml"); + File.Exists("asyncapi.json").ShouldBeTrue("asyncapi.json"); + + var yml = File.ReadAllText("asyncapi.yml"); + yml.ShouldBe(""" + 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 + """, "yaml"); + + var yaml = File.ReadAllText("asyncapi.yaml"); + yaml.ShouldBe(yml, "yml"); + + var json = File.ReadAllText("asyncapi.json"); + json.ShouldBe(""" + { + "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" + } + } + } + } + """, "json"); + } +}