From 23b8e4cbee231b91bd97956fe752d67f3d29436f Mon Sep 17 00:00:00 2001 From: Senn Geerts Date: Sat, 20 Jul 2024 15:24:43 +0200 Subject: [PATCH] #207 generate code from spec: json only code generation --- .../StreetlightsAPI.AsyncApiSpecFirst.csproj | 10 +- .../AsyncAPI.Saunter.Generator.Build.targets | 38 ++--- .../AsyncAPI.Saunter.Generator.Cli.csproj | 3 + .../AsyncApiInterface/AsyncApiGenerator.cs | 138 ++++++++++++++++++ .../AsyncApiInterface/AsyncApiState.cs | 6 + .../FromSpec/DataTypes/DataTypesGenerator.cs | 41 ++++++ .../DataTypes/DataTypesGeneratorState.cs | 8 + .../DataTypes/OpenApiCompatibility.cs | 23 +++ .../FromSpec/FromSpecCommand.cs | 75 ++++++++-- .../FromSpec/GeneratorOptions.cs | 3 + .../FromSpec/ServiceExtensions.cs | 15 ++ src/AsyncAPI.Saunter.Generator.Cli/Program.cs | 1 + 12 files changed, 325 insertions(+), 36 deletions(-) create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiGenerator.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiState.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGenerator.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGeneratorState.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/OpenApiCompatibility.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/FromSpec/GeneratorOptions.cs create mode 100644 src/AsyncAPI.Saunter.Generator.Cli/FromSpec/ServiceExtensions.cs diff --git a/examples/StreetlightsAPI.AsyncApiSpecFirst/StreetlightsAPI.AsyncApiSpecFirst.csproj b/examples/StreetlightsAPI.AsyncApiSpecFirst/StreetlightsAPI.AsyncApiSpecFirst.csproj index 858e390..73a16aa 100644 --- a/examples/StreetlightsAPI.AsyncApiSpecFirst/StreetlightsAPI.AsyncApiSpecFirst.csproj +++ b/examples/StreetlightsAPI.AsyncApiSpecFirst/StreetlightsAPI.AsyncApiSpecFirst.csproj @@ -10,8 +10,8 @@ - - + + @@ -46,7 +46,6 @@ - @@ -61,7 +60,10 @@ - + + + + diff --git a/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.targets b/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.targets index 858b202..6e35260 100644 --- a/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.targets +++ b/src/AsyncAPI.Saunter.Generator.Build/build/AsyncAPI.Saunter.Generator.Build.targets @@ -3,8 +3,8 @@ - - + + @@ -15,13 +15,18 @@ $([System.IO.Path]::Combine($(AsyncAPIBuildToolRoot), tools, net8.0, AsyncAPI.Saunter.Generator.Cli.dll)) - - - + + + + + + + + - $([System.IO.Path]::Combine($(MSBuildProjectDirectory), $(OutputPath), $(AssemblyTitle).dll)) @@ -30,17 +35,13 @@ - - - - - - - - - - - + + + + + + + @@ -48,10 +49,9 @@ - - diff --git a/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj b/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj index f2483a8..e4eeaae 100644 --- a/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj +++ b/src/AsyncAPI.Saunter.Generator.Cli/AsyncAPI.Saunter.Generator.Cli.csproj @@ -39,11 +39,14 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiGenerator.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiGenerator.cs new file mode 100644 index 0000000..48a7de7 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiGenerator.cs @@ -0,0 +1,138 @@ +using System.Reflection; +using System.Text; +using CaseConverter; +using LEGO.AsyncAPI.Models; +using LEGO.AsyncAPI.Readers; + +namespace AsyncAPI.Saunter.Generator.Cli.FromSpec.AsyncApiInterface; + +internal interface IAsyncApiGenerator +{ + string GenerateAsyncApiInterfaces(GeneratorOptions options, string text, AsyncApiState state); + + string GenerateAsyncApiInterfaces(GeneratorOptions options, AsyncApiDocument asyncApi, AsyncApiState state); +} + +internal class AsyncApiGenerator : IAsyncApiGenerator +{ + private readonly string _version; + private readonly string _name; + + public AsyncApiGenerator() + { + var assembly = Assembly.GetExecutingAssembly().GetName(); + + this._version = assembly.Version!.ToString(); + this._name = assembly.Name; + } + + private static string MakeGlobalDocumentTopic(string name) => $"TOPIC_{name.ToSnakeCase().ToUpperInvariant()}"; + + public string GenerateAsyncApiInterfaces(GeneratorOptions options, string text, AsyncApiState state) + { + var asyncApi = new AsyncApiStringReader().Read(text, out var diagnostic); + return this.GenerateAsyncApiInterfaces(options, asyncApi, state); + } + + public string GenerateAsyncApiInterfaces(GeneratorOptions options, AsyncApiDocument asyncApi, AsyncApiState state) + { + var sb = new StringBuilder( + $$""" + //---------------------- + // + // Generated using {{this._name}} v{{this._version}} + // At: {{DateTime.Now:U}} + // + //---------------------- + + using Saunter.Attributes; + using System.CodeDom.Compiler; + + namespace {{options.Namespace}} + { + {{this.GetGeneratedCodeAttributeLine()}} + public static partial class {{options.TopicsClassName}} + { + """); + + sb.AppendLine(); + sb.AppendLine($" public const string {MakeGlobalDocumentTopic(options.ClassName)} = \"{options.ClassName.ToPascalCase()}\";"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // Commands + this.AddInterface(sb, options, options.ClassName, $"{options.ClassName}Commands", new("SubscribeOperation", "command", x => x.Subscribe), asyncApi.Channels.Where(x => x.Value.Subscribe != null)); + sb.AppendLine(); + + // Events + this.AddInterface(sb, options, options.ClassName, $"{options.ClassName}Events", new("PublishOperation", "evt", x => x.Publish), asyncApi.Channels.Where(x => x.Value.Publish != null)); + + sb.AppendLine("}"); // close namespace + sb.AppendLine(); + + state.Documents.Add(options.ClassName); + var contents = sb.ToString(); + return contents; + } + + private string GetGeneratedCodeAttributeLine() => $" [GeneratedCodeAttribute(\"{this._name}\", \"{this._version}\")]"; + + private void AddInterface(StringBuilder sb, GeneratorOptions genOptions, string specName, string className, OperationOptions options, IEnumerable> channels) + { + sb.AppendLine($" [AsyncApi({genOptions.TopicsClassName}.{MakeGlobalDocumentTopic(specName)})]"); + sb.AppendLine(this.GetGeneratedCodeAttributeLine()); + sb.AppendLine($" public interface I{className}"); + sb.Append(" {"); + + foreach (var channel in channels) + { + AddChannelOperation(sb, options, channel); + } + + sb.AppendLine(" }"); + } + + private record OperationOptions(string OperationAttribute, string VarName, Func OperationProvider); + + private static void AddChannelOperation(StringBuilder sb, OperationOptions options, KeyValuePair channel) + { + var operation = options.OperationProvider(channel.Value); + + sb.AppendLine(); + sb.AppendLine($" [Channel(\"{channel.Key}\")]"); + var channelParametersSb = new StringBuilder(); + foreach (var channelParameter in channel.Value.Parameters) + { + var refName = FromReference(channelParameter.Value.Reference.Reference); + var typeName = refName.ToPascalCase(); + if (channelParameter.Value.Schema.Enum.Any()) + { + typeName += "s"; + } + var varName = refName.ToCamelCase(); + sb.AppendLine($" [ChannelParameter(\"{channelParameter.Key}\", typeof({typeName}), Description = \"{channelParameter.Value.Description}\")]"); + + channelParametersSb.Append(", "); + channelParametersSb.Append(typeName); + channelParametersSb.Append(" "); + channelParametersSb.Append(varName); + } + + var msg = operation.Message.Single(); + var msgTypeName = (msg.Name ?? msg.Payload.Reference.Id).ToPascalCase(); + sb.AppendLine($" [{options.OperationAttribute}(typeof({msgTypeName}), Summary = \"{operation.Summary}\", Description = \"{operation.Description}\")]"); + sb.Append($" void {operation.OperationId}({msgTypeName} {options.VarName}"); + sb.Append(channelParametersSb); + sb.AppendLine(");"); + } + + private static string FromReference(string reference) + { + var prefix = "#/components/parameters/"; + if (reference.IndexOf(prefix) == 0) + { + return reference[prefix.Length..]; + } + throw new ArgumentException($"Invalid reference: {reference}"); + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiState.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiState.cs new file mode 100644 index 0000000..58f2495 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/AsyncApiInterface/AsyncApiState.cs @@ -0,0 +1,6 @@ +namespace AsyncAPI.Saunter.Generator.Cli.FromSpec.AsyncApiInterface; + +internal class AsyncApiState +{ + public List Documents { get; } = new(); +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGenerator.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGenerator.cs new file mode 100644 index 0000000..95c8c91 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGenerator.cs @@ -0,0 +1,41 @@ +using NJsonSchema; +using NSwag.CodeGeneration.CSharp; +using NSwag; +using NJsonSchema.CodeGeneration.CSharp; + + +namespace AsyncAPI.Saunter.Generator.Cli.FromSpec.DataTypes; + +internal interface IDataTypesGenerator +{ + Task GenerateDataTypesAsync(GeneratorOptions options, string spec, DataTypesGeneratorState state); +} + +internal class NSwagGenerator : IDataTypesGenerator +{ + public async Task GenerateDataTypesAsync(GeneratorOptions options, string spec, DataTypesGeneratorState state) + { + spec = OpenApiCompatibility.PrepareSpecFile(spec); + + var document = await OpenApiDocument.FromJsonAsync(spec).ConfigureAwait(false); + var settings = new CSharpClientGeneratorSettings + { + CSharpGeneratorSettings = + { + Namespace = options.Namespace, + SchemaType = SchemaType.OpenApi3, + ClassStyle = CSharpClassStyle.Record, + ExcludedTypeNames = state.AlreadyGeneratedDataTypes.ToArray(), + }, + GenerateClientClasses = false, + AdditionalNamespaceUsages = state.AlreadyGeneratedNamespaces.ToArray() + }; + + var generator = new CSharpClientGenerator(document, settings); + var contents = generator.GenerateFile(); + state.AlreadyGeneratedDataTypes.AddRange(document.Definitions.Keys); + state.AlreadyGeneratedNamespaces.Add(options.Namespace); + + return contents; + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGeneratorState.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGeneratorState.cs new file mode 100644 index 0000000..71bdbed --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/DataTypesGeneratorState.cs @@ -0,0 +1,8 @@ +namespace AsyncAPI.Saunter.Generator.Cli.FromSpec.DataTypes; + +internal class DataTypesGeneratorState +{ + public List AlreadyGeneratedDataTypes { get; } = new(); + + public List AlreadyGeneratedNamespaces { get; } = new(); +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/OpenApiCompatibility.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/OpenApiCompatibility.cs new file mode 100644 index 0000000..9d6f10b --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/DataTypes/OpenApiCompatibility.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; + +namespace AsyncAPI.Saunter.Generator.Cli.FromSpec.DataTypes; + +internal static class OpenApiCompatibility +{ + internal static string PrepareSpecFile(string spec) + { + var json = (JObject)JsonConvert.DeserializeObject(spec); + // the type is important for NSwag + if (!json.ContainsKey("openapi")) + { + json.Add("openapi", "3.0.1"); + } + // NSwag doesn't understand the servers format of AsyncApi, and it is not needed anyway. + if (json.ContainsKey("servers")) + { + json.Remove("servers"); + } + return JsonConvert.SerializeObject(json); + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/FromSpecCommand.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/FromSpecCommand.cs index fcb401a..5c365c8 100644 --- a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/FromSpecCommand.cs +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/FromSpecCommand.cs @@ -1,39 +1,88 @@ -using ConsoleAppFramework; -using LEGO.AsyncAPI; -using LEGO.AsyncAPI.Models; +using System.Diagnostics; +using System.Text; +using AsyncAPI.Saunter.Generator.Cli.FromSpec.AsyncApiInterface; +using AsyncAPI.Saunter.Generator.Cli.FromSpec.DataTypes; +using CaseConverter; +using ConsoleAppFramework; using Microsoft.Extensions.Logging; namespace AsyncAPI.Saunter.Generator.Cli.FromSpec; -internal class FromSpecCommand(ILogger logger) +internal class FromSpecCommand(ILogger logger, IAsyncApiGenerator asyncApiGenerator, IDataTypesGenerator dataTypesGenerator) { /// /// Retrieves AsyncAPI spec from a startup assembly and writes to file. /// - /// the AsyncAPI specification to generate code for. Parameter should include the namespace: namespace,outputPath,asyncapi.json + /// the AsyncAPI specification to generate code for. Parameter should include 3 parts: namespace,outputDirectory,asyncapiSpec [Command("fromspec")] - public int FromSpec(params string[] specs) + public async Task FromSpec(params string[] specs) { logger.LogInformation($"FromSpec(#{specs.Length}): --specs {string.Join(';', specs)}"); - foreach (var (namespaceName, output, specName) in Split(specs)) + var specsToGenerate = Split(specs); + var output = specsToGenerate.ToDictionary(x => x, _ => new StringBuilder()); + + // Common + var topicsClassName = "Topics"; + foreach (var (spec, _) in output) + { + Directory.CreateDirectory(spec.OutputDirectory); + + if (!File.Exists(spec.SpecFile)) + { + throw new ArgumentException($"Provided spec does not exist: {Path.GetFullPath(spec.SpecFile)}."); + } + } + + // AsyncAPI Interface + var aaState = new AsyncApiState(); + foreach (var (spec, sb) in output) + { + var options = new GeneratorOptions($"{spec.NamespaceName}.{spec.SpecName}.Api", spec.SpecName, $"{topicsClassName}"); + var contents = asyncApiGenerator.GenerateAsyncApiInterfaces(options, spec.Contents, aaState); + sb.Append(contents); + } + + // DataTypes + var nsState = new DataTypesGeneratorState(); + foreach (var (spec, sb) in output) { - Directory.CreateDirectory(output); - var outputFile = Path.Combine(output, $"{Path.GetFileNameWithoutExtension(specName)}.g.cs"); - File.Create(outputFile); - logger.LogInformation($"Created {Path.GetFullPath(outputFile)}"); + var options = new GeneratorOptions($"{spec.NamespaceName}.{spec.SpecName}.Api", spec.SpecName, $"{topicsClassName}"); + var contents = await dataTypesGenerator.GenerateDataTypesAsync(options, spec.Contents, nsState).ConfigureAwait(false); + sb.Append(contents); } + + // Write to file + foreach (var (spec, sb) in output) + { + var contents = sb.ToString(); + var outputFile = spec.OutputFileName; + await File.WriteAllTextAsync(outputFile, contents, Encoding.UTF8); + logger.LogInformation($"Created {outputFile} (size: {contents.Length:N0} chars)"); + } + return 0; } - private static IEnumerable<(string namespaceName, string output, string specName)> Split(IEnumerable input) + private record SpecToGenerate(string NamespaceName, string OutputDirectory, string SpecFile) + { + private string _contents; + + public string Contents => this._contents ??= File.ReadAllText(this.SpecFile); + + public string SpecName => Path.GetFileNameWithoutExtension(this.SpecFile).ToPascalCase(); + + public string OutputFileName => Path.GetFullPath(Path.Combine(this.OutputDirectory, $"{this.SpecName}.g.cs")); + } + + private static IEnumerable Split(IEnumerable input) { foreach (var spec in input) { var split = spec.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToList(); if (split.Count == 3 && !split.Any(string.IsNullOrWhiteSpace)) { - yield return (split[0], split[1], split[2]); + yield return new SpecToGenerate(NamespaceName: split[0], OutputDirectory: split[1], SpecFile: split[2]); } } } diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/GeneratorOptions.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/GeneratorOptions.cs new file mode 100644 index 0000000..6ee53d8 --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/GeneratorOptions.cs @@ -0,0 +1,3 @@ +namespace AsyncAPI.Saunter.Generator.Cli.FromSpec; + +internal record GeneratorOptions(string Namespace, string ClassName, string TopicsClassName); diff --git a/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/ServiceExtensions.cs b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/ServiceExtensions.cs new file mode 100644 index 0000000..e4bb95b --- /dev/null +++ b/src/AsyncAPI.Saunter.Generator.Cli/FromSpec/ServiceExtensions.cs @@ -0,0 +1,15 @@ +using AsyncAPI.Saunter.Generator.Cli.FromSpec.AsyncApiInterface; +using AsyncAPI.Saunter.Generator.Cli.FromSpec.DataTypes; +using Microsoft.Extensions.DependencyInjection; + +namespace AsyncAPI.Saunter.Generator.Cli.FromSpec; + +internal static class ServiceExtensions +{ + public static IServiceCollection AddFromSpecCommand(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + return services; + } +} diff --git a/src/AsyncAPI.Saunter.Generator.Cli/Program.cs b/src/AsyncAPI.Saunter.Generator.Cli/Program.cs index 3c7018d..5cc2552 100644 --- a/src/AsyncAPI.Saunter.Generator.Cli/Program.cs +++ b/src/AsyncAPI.Saunter.Generator.Cli/Program.cs @@ -7,6 +7,7 @@ var services = new ServiceCollection(); services.AddLogging(builder => builder.AddSimpleConsole(x => x.SingleLine = true).SetMinimumLevel(LogLevel.Trace)); services.AddToFileCommand(); +services.AddFromSpecCommand(); using var serviceProvider = services.BuildServiceProvider(); var logger = serviceProvider.GetRequiredService>();