diff --git a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/AbpCliModule.cs b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/AbpCliModule.cs index 4439a5c66..bb286cacd 100644 --- a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/AbpCliModule.cs +++ b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/AbpCliModule.cs @@ -16,8 +16,9 @@ namespace LINGYUN.Abp.Cli Configure(options => { options.Commands.Clear(); - options.Commands["help"] = typeof(HelpCommand); - options.Commands["create"] = typeof(CreateCommand); + options.Commands[HelpCommand.Name] = typeof(HelpCommand); + options.Commands[CreateCommand.Name] = typeof(CreateCommand); + options.Commands[GenerateProxyCommand.Name] = typeof(GenerateProxyCommand); }); } } diff --git a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/CreateCommand.cs b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/CreateCommand.cs index fff109ddf..e948aa07e 100644 --- a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/CreateCommand.cs +++ b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/CreateCommand.cs @@ -19,6 +19,10 @@ namespace LINGYUN.Abp.Cli.Commands { public class CreateCommand : IConsoleCommand, ITransientDependency { + public const string Name = "create"; + + protected string CommandName => Name; + public class FindFile { public string Path { get; } diff --git a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/GenerateProxyCommand.cs b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/GenerateProxyCommand.cs new file mode 100644 index 000000000..c1f490609 --- /dev/null +++ b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/GenerateProxyCommand.cs @@ -0,0 +1,146 @@ +using LINGYUN.Abp.Cli.ServiceProxying; +using LINGYUN.Abp.Cli.ServiceProxying.CSharp; +using Microsoft.Extensions.DependencyInjection; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Volo.Abp.Cli.Args; +using Volo.Abp.Cli.Commands; +using Volo.Abp.DependencyInjection; + +using VoloGenerateProxyArgs = Volo.Abp.Cli.ServiceProxying.GenerateProxyArgs; + +namespace LINGYUN.Abp.Cli.Commands; + +public class GenerateProxyCommand : IConsoleCommand, ITransientDependency +{ + public const string Name = "generate-proxy"; + + protected string CommandName => Name; + + protected IServiceScopeFactory ServiceScopeFactory { get; } + + public GenerateProxyCommand( + IServiceScopeFactory serviceScopeFactory) + { + ServiceScopeFactory = serviceScopeFactory; + } + + public async Task ExecuteAsync(CommandLineArgs commandLineArgs) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var serviceProxyGenerator = scope.ServiceProvider.GetRequiredService(); + + await serviceProxyGenerator.GenerateProxyAsync(BuildArgs(commandLineArgs)); + } + } + + private VoloGenerateProxyArgs BuildArgs(CommandLineArgs commandLineArgs) + { + var provider = commandLineArgs.Options.GetOrNull(Options.Provider.Short, Options.Provider.Long); + var url = commandLineArgs.Options.GetOrNull(Options.Url.Short, Options.Url.Long); + var target = commandLineArgs.Options.GetOrNull(Options.Target.Long); + var module = commandLineArgs.Options.GetOrNull(Options.Module.Short, Options.Module.Long) ?? "app"; + var output = commandLineArgs.Options.GetOrNull(Options.Output.Short, Options.Output.Long); + var apiName = commandLineArgs.Options.GetOrNull(Options.ApiName.Short, Options.ApiName.Long); + var source = commandLineArgs.Options.GetOrNull(Options.Source.Short, Options.Source.Long); + var workDirectory = commandLineArgs.Options.GetOrNull(Options.WorkDirectory.Short, Options.WorkDirectory.Long) ?? Directory.GetCurrentDirectory(); + var folder = commandLineArgs.Options.GetOrNull(Options.Folder.Long); + + return new GenerateProxyArgs(CommandName, workDirectory, module, url, output, target, apiName, source, folder, provider, commandLineArgs.Options); + } + + public string GetUsageInfo() + { + var sb = new StringBuilder(); + + sb.AppendLine(""); + sb.AppendLine("Usage:"); + sb.AppendLine(""); + sb.AppendLine($" labp {CommandName}"); + sb.AppendLine(""); + sb.AppendLine("Options:"); + sb.AppendLine(""); + sb.AppendLine("-m|--module (default: 'app') The name of the backend module you wish to generate proxies for."); + sb.AppendLine("-wd|--working-directory Execution directory."); + sb.AppendLine("-u|--url API definition URL from."); + sb.AppendLine("-p|--provider The client proxy provider(http, dapr)."); + sb.AppendLine("See the documentation for more info: https://docs.abp.io/en/abp/latest/CLI"); + + sb.AppendLine(""); + sb.AppendLine("Examples:"); + sb.AppendLine(""); + sb.AppendLine(" labp generate-proxy"); + sb.AppendLine(" labp generate-proxy -p dapr"); + sb.AppendLine(" labp generate-proxy -m identity -o Pages/Identity/client-proxies.js -url https://localhost:44302/"); + sb.AppendLine(" labp generate-proxy --folder MyProxies/InnerFolder -url https://localhost:44302/"); + + return sb.ToString(); + } + + public string GetShortDescription() + { + return "Generates client service proxies and DTOs to consume HTTP APIs."; + } + + public static class Options + { + public static class Provider + { + public const string Short = "p"; + public const string Long = "provider"; + } + + public static class Module + { + public const string Short = "m"; + public const string Long = "module"; + } + + public static class ApiName + { + public const string Short = "a"; + public const string Long = "api-name"; + } + + public static class Source + { + public const string Short = "s"; + public const string Long = "source"; + } + public static class Output + { + public const string Short = "o"; + public const string Long = "output"; + } + + public static class Target + { + public const string Long = "target"; + } + + public static class Prompt + { + public const string Short = "p"; + public const string Long = "prompt"; + } + + public static class Folder + { + public const string Long = "folder"; + } + + public static class Url + { + public const string Short = "u"; + public const string Long = "url"; + } + + public static class WorkDirectory + { + public const string Short = "wd"; + public const string Long = "working-directory"; + } + } +} diff --git a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/HelpCommand.cs b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/HelpCommand.cs index 4bf6038c5..e9f956b05 100644 --- a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/HelpCommand.cs +++ b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/Commands/HelpCommand.cs @@ -14,6 +14,10 @@ namespace LINGYUN.Abp.Cli.Commands { public class HelpCommand : IConsoleCommand, ITransientDependency { + public const string Name = "help"; + + protected string CommandName => Name; + public ILogger Logger { get; set; } protected AbpCliOptions AbpCliOptions { get; } protected IServiceScopeFactory ServiceScopeFactory { get; } diff --git a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/ServiceProxying/CSharp/CSharpServiceProxyGenerator.cs b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/ServiceProxying/CSharp/CSharpServiceProxyGenerator.cs new file mode 100644 index 000000000..3c368c8eb --- /dev/null +++ b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/ServiceProxying/CSharp/CSharpServiceProxyGenerator.cs @@ -0,0 +1,422 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Volo.Abp.Cli; +using Volo.Abp.Cli.Commands; +using Volo.Abp.Cli.Http; +using Volo.Abp.Cli.ServiceProxying; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Json; + +using VoloGenerateProxyArgs = Volo.Abp.Cli.ServiceProxying.GenerateProxyArgs; + +namespace LINGYUN.Abp.Cli.ServiceProxying.CSharp; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IServiceProxyGenerator), typeof(CSharpServiceProxyGenerator))] +public class CSharpServiceProxyGenerator : ServiceProxyGeneratorBase, ITransientDependency +{ + public const string Name = "CSHARP"; + + private const string UsingPlaceholder = ""; + private const string MethodPlaceholder = ""; + private const string ClassName = ""; + private const string ServiceInterface = ""; + private readonly static string[] ServicePostfixes = { "AppService", "ApplicationService" , "Service"}; + private const string DefaultNamespace = "ClientProxies"; + private const string Namespace = ""; + private const string DefaultProvider = "ClientProxyBase"; + private const string Provider = ""; + private const string AppServicePrefix = "Volo.Abp.Application.Services"; + private readonly string _clientProxyGeneratedTemplate = "// This file is automatically generated by ABP framework to use MVC Controllers from CSharp" + + $"{Environment.NewLine}" + + $"{Environment.NewLine}" + + $"{Environment.NewLine}// ReSharper disable once CheckNamespace" + + $"{Environment.NewLine}namespace ;" + + $"{Environment.NewLine}" + + $"{Environment.NewLine}[Dependency(ReplaceServices = true)]" + + $"{Environment.NewLine}[ExposeServices(typeof(), typeof())]" + + $"{Environment.NewLine}public partial class : <>, " + + $"{Environment.NewLine}{{" + + $"{Environment.NewLine} " + + $"{Environment.NewLine}}}" + + $"{Environment.NewLine}"; + private readonly string _clientProxyTemplate = "// This file is part of , you can customize it here" + + $"{Environment.NewLine}// ReSharper disable once CheckNamespace" + + $"{Environment.NewLine}namespace ;" + + $"{Environment.NewLine}" + + $"{Environment.NewLine}public partial class " + + $"{Environment.NewLine}{{" + + $"{Environment.NewLine}}}" + + $"{Environment.NewLine}"; + private readonly List _usingNamespaceList = new() + { + "using System;", + "using System.Threading.Tasks;", + "using Volo.Abp.Application.Dtos;", + "using Volo.Abp.Http.Client;", + "using Volo.Abp.Http.Modeling;", + "using Volo.Abp.DependencyInjection;", + "using Volo.Abp.Http.Client.ClientProxying;", + "using LINGYUN.Abp.Dapr;", + "using LINGYUN.Abp.Dapr.Client;", + "using LINGYUN.Abp.Dapr.Client.ClientProxying;" + }; + + public CSharpServiceProxyGenerator( + CliHttpClientFactory cliHttpClientFactory, + IJsonSerializer jsonSerializer) : + base(cliHttpClientFactory, jsonSerializer) + { + } + + public async override Task GenerateProxyAsync(VoloGenerateProxyArgs args) + { + CheckWorkDirectory(args.WorkDirectory); + CheckFolder(args.Folder); + + if (args.CommandName == RemoveProxyCommand.Name) + { + RemoveClientProxyFile(args); + return; + } + + var applicationApiDescriptionModel = await GetApplicationApiDescriptionModelAsync(args); + + foreach (var controller in applicationApiDescriptionModel.Modules.Values.SelectMany(x => x.Controllers)) + { + if (ShouldGenerateProxy(controller.Value)) + { + await GenerateClientProxyFileAsync(args, controller.Value); + } + } + + await CreateGenerateProxyJsonFile(args, applicationApiDescriptionModel); + } + + private async Task CreateGenerateProxyJsonFile(VoloGenerateProxyArgs args, ApplicationApiDescriptionModel applicationApiDescriptionModel) + { + var folder = args.Folder.IsNullOrWhiteSpace() ? DefaultNamespace : args.Folder; + var filePath = Path.Combine(args.WorkDirectory, folder, $"{args.Module}-generate-proxy.json"); + + using (var writer = new StreamWriter(filePath)) + { + await writer.WriteAsync(JsonSerializer.Serialize(applicationApiDescriptionModel, indented: true)); + } + } + + private void RemoveClientProxyFile(VoloGenerateProxyArgs args) + { + var folder = args.Folder.IsNullOrWhiteSpace() ? DefaultNamespace : args.Folder; + var folderPath = Path.Combine(args.WorkDirectory, folder); + + if (Directory.Exists(folderPath)) + { + Directory.Delete(folderPath, true); + } + + Logger.LogInformation($"Delete {GetLoggerOutputPath(folderPath, args.WorkDirectory)}"); + } + + private async Task GenerateClientProxyFileAsync( + VoloGenerateProxyArgs args, + ControllerApiDescriptionModel controllerApiDescription) + { + var folder = args.Folder.IsNullOrWhiteSpace() ? DefaultNamespace : args.Folder; + + var appServiceTypeFullName = controllerApiDescription.Interfaces.Last().Type; + var appServiceTypeName = appServiceTypeFullName.Split('.').Last(); + var clientProxyName = $"{controllerApiDescription.ControllerName}ClientProxy"; + var clientProvider = args.As().Provider; + var rootNamespace = $"{GetTypeNamespace(controllerApiDescription.Type)}.{folder.Replace('/', '.')}"; + var clientProxyBuilder = new StringBuilder(_clientProxyTemplate); + clientProxyBuilder.Replace(ClassName, clientProxyName); + clientProxyBuilder.Replace(Namespace, rootNamespace); + clientProxyBuilder.Replace(Provider, clientProvider); + + var filePath = Path.Combine(args.WorkDirectory, folder, $"{clientProxyName}.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)); + + if (!File.Exists(filePath)) + { + using (var writer = new StreamWriter(filePath)) + { + await writer.WriteAsync(clientProxyBuilder.ToString()); + } + + Logger.LogInformation($"Create {GetLoggerOutputPath(filePath, args.WorkDirectory)}"); + } + + await GenerateClientProxyGeneratedFileAsync( + args, + controllerApiDescription, + clientProxyName, + appServiceTypeName, + appServiceTypeFullName, + rootNamespace, + folder); + } + + private async Task GenerateClientProxyGeneratedFileAsync( + VoloGenerateProxyArgs args, + ControllerApiDescriptionModel controllerApiDescription, + string clientProxyName, + string appServiceTypeName, + string appServiceTypeFullName, + string rootNamespace, + string folder) + { + var clientProxyBuilder = new StringBuilder(_clientProxyGeneratedTemplate); + + var usingNamespaceList = new List(_usingNamespaceList) + { + $"using {GetTypeNamespace(appServiceTypeFullName)};" + }; + + clientProxyBuilder.Replace(ClassName, clientProxyName); + clientProxyBuilder.Replace(Namespace, rootNamespace); + clientProxyBuilder.Replace(ServiceInterface, appServiceTypeName); + + foreach (var action in controllerApiDescription.Actions.Values) + { + if (!ShouldGenerateMethod(appServiceTypeFullName, action)) + { + continue; + } + + GenerateMethod(action, clientProxyBuilder, usingNamespaceList); + } + + foreach (var usingNamespace in usingNamespaceList) + { + clientProxyBuilder.Replace($"{UsingPlaceholder}", $"{usingNamespace}{Environment.NewLine}{UsingPlaceholder}"); + } + + clientProxyBuilder.Replace($"{Environment.NewLine}{UsingPlaceholder}", string.Empty); + clientProxyBuilder.Replace($"{Environment.NewLine}{Environment.NewLine} {MethodPlaceholder}", string.Empty); + + var filePath = Path.Combine(args.WorkDirectory, folder, $"{clientProxyName}.Generated.cs"); + + using (var writer = new StreamWriter(filePath)) + { + await writer.WriteAsync(clientProxyBuilder.ToString()); + Logger.LogInformation($"Create {GetLoggerOutputPath(filePath, args.WorkDirectory)}"); + } + } + + private void GenerateMethod( + ActionApiDescriptionModel action, + StringBuilder clientProxyBuilder, + List usingNamespaceList) + { + var methodBuilder = new StringBuilder(); + + var returnTypeName = GetRealTypeName(action.ReturnValue.Type, usingNamespaceList); + + if (!action.Name.EndsWith("Async")) + { + GenerateSynchronizationMethod(action, returnTypeName, methodBuilder, usingNamespaceList); + clientProxyBuilder.Replace(MethodPlaceholder, $"{methodBuilder}{Environment.NewLine} {MethodPlaceholder}"); + return; + } + + GenerateAsynchronousMethod(action, returnTypeName, methodBuilder, usingNamespaceList); + clientProxyBuilder.Replace(MethodPlaceholder, $"{methodBuilder}{Environment.NewLine} {MethodPlaceholder}"); + } + + private void GenerateSynchronizationMethod(ActionApiDescriptionModel action, string returnTypeName, StringBuilder methodBuilder, List usingNamespaceList) + { + methodBuilder.AppendLine($"public virtual {returnTypeName} {action.Name}()"); + + foreach (var parameter in action.Parameters.GroupBy(x => x.Name).Select(x => x.First())) + { + methodBuilder.Replace("", $"{GetRealTypeName(parameter.Type, usingNamespaceList)} {parameter.Name}, "); + } + + methodBuilder.Replace("", string.Empty); + methodBuilder.Replace(", )", ")"); + + methodBuilder.AppendLine(" {"); + methodBuilder.AppendLine(" //Client Proxy does not support the synchronization method, you should always use asynchronous methods as a best practice"); + methodBuilder.AppendLine(" throw new System.NotImplementedException(); "); + methodBuilder.AppendLine(" }"); + } + + private void GenerateAsynchronousMethod( + ActionApiDescriptionModel action, + string returnTypeName, + StringBuilder methodBuilder, + List usingNamespaceList) + { + var returnSign = returnTypeName == "void" ? "Task" : $"Task<{returnTypeName}>"; + + methodBuilder.AppendLine($"public virtual async {returnSign} {action.Name}()"); + + foreach (var parameter in action.ParametersOnMethod) + { + methodBuilder.Replace("", $"{GetRealTypeName(parameter.Type, usingNamespaceList)} {parameter.Name}, "); + } + + methodBuilder.Replace("", string.Empty); + methodBuilder.Replace(", )", ")"); + + methodBuilder.AppendLine(" {"); + + var argsTemplate = "new ClientProxyRequestTypeValue" + + $"{Environment.NewLine} {{" + + $"{Environment.NewLine} }}"; + + var args = action.ParametersOnMethod.Any() ? argsTemplate : string.Empty; + + if (returnTypeName == "void") + { + methodBuilder.AppendLine($" await RequestAsync(nameof({action.Name}), {args});"); + } + else + { + methodBuilder.AppendLine($" return await RequestAsync<{returnTypeName}>(nameof({action.Name}), {args});"); + } + + foreach (var parameter in action.ParametersOnMethod) + { + methodBuilder.Replace("", $"{Environment.NewLine} {{ typeof({GetRealTypeName(parameter.Type)}), {parameter.Name} }},"); + } + + methodBuilder.Replace(",", string.Empty); + methodBuilder.Replace(", )", ")"); + methodBuilder.AppendLine(" }"); + } + + private bool ShouldGenerateProxy(ControllerApiDescriptionModel controllerApiDescription) + { + if (!controllerApiDescription.Interfaces.Any()) + { + return false; + } + + var serviceInterface = controllerApiDescription.Interfaces.Last(); + return ServicePostfixes.Any(x => serviceInterface.Type.EndsWith(x)); + } + + private bool ShouldGenerateMethod(string appServiceTypeName, ActionApiDescriptionModel action) + { + return action.ImplementFrom.StartsWith(AppServicePrefix) || action.ImplementFrom.StartsWith(appServiceTypeName); + } + + private string GetTypeNamespace(string typeFullName) + { + return typeFullName.Substring(0, typeFullName.LastIndexOf('.')); + } + + private string GetRealTypeName(string typeName, List usingNamespaceList = null) + { + var filter = new[] { "<", ",", ">" }; + var stringBuilder = new StringBuilder(); + var typeNames = typeName.Split('.'); + + if (typeNames.All(x => !filter.Any(x.Contains))) + { + if (usingNamespaceList != null) + { + AddUsingNamespace(usingNamespaceList, typeName); + } + + return NormalizeTypeName(typeNames.Last()); + } + + var fullName = string.Empty; + + foreach (var item in typeNames) + { + if (filter.Any(x => item.Contains(x))) + { + if (usingNamespaceList != null) + { + AddUsingNamespace(usingNamespaceList, $"{fullName}.{item}".TrimStart('.')); + } + + fullName = string.Empty; + + if (item.Contains('<') || item.Contains(',')) + { + stringBuilder.Append(item.Substring(0, item.IndexOf(item.Contains('<') ? '<' : ',') + 1)); + fullName = item.Substring(item.IndexOf(item.Contains('<') ? '<' : ',') + 1); + } + else + { + stringBuilder.Append(item); + } + } + else + { + fullName = $"{fullName}.{item}"; + } + } + + return stringBuilder.ToString(); + } + + private void AddUsingNamespace(List usingNamespaceList, string typeName) + { + var rootNamespace = $"using {GetTypeNamespace(typeName)};"; + if (usingNamespaceList.Contains(rootNamespace)) + { + return; + } + + usingNamespaceList.Add(rootNamespace); + } + + private string NormalizeTypeName(string typeName) + { + var nullable = string.Empty; + if (typeName.EndsWith("?")) + { + typeName = typeName.TrimEnd('?'); + nullable = "?"; + } + + typeName = typeName switch + { + "Void" => "void", + "Boolean" => "bool", + "String" => "string", + "Int32" => "int", + "Int64" => "long", + "Double" => "double", + "Object" => "object", + "Byte" => "byte", + "Char" => "char", + _ => typeName + }; + + return $"{typeName}{nullable}"; + } + + private void CheckWorkDirectory(string directory) + { + if (!Directory.Exists(directory)) + { + throw new CliUsageException("Specified directory does not exist."); + } + + var projectFiles = Directory.GetFiles(directory, "*.csproj"); + if (!projectFiles.Any()) + { + throw new CliUsageException("No project file(csproj) found in the directory."); + } + } + + private void CheckFolder(string folder) + { + if (!folder.IsNullOrWhiteSpace() && Path.HasExtension(folder)) + { + throw new CliUsageException("Option folder should be a directory."); + } + } +} diff --git a/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/ServiceProxying/GenerateProxyArgs.cs b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/ServiceProxying/GenerateProxyArgs.cs new file mode 100644 index 000000000..8be4f1660 --- /dev/null +++ b/aspnet-core/modules/cli/LINGYUN.Abp.Cli/LINGYUN/Abp/Cli/ServiceProxying/GenerateProxyArgs.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace LINGYUN.Abp.Cli.ServiceProxying; + +public class GenerateProxyArgs : Volo.Abp.Cli.ServiceProxying.GenerateProxyArgs +{ + public string Provider { get; } + + public GenerateProxyArgs( + [NotNull] string commandName, + [NotNull] string workDirectory, + string module, + string url, + string output, + string target, + string apiName, + string source, + string folder, + string provider, + Dictionary extraProperties = null) + : base(commandName, workDirectory, module, url, output, target, apiName, source, folder, extraProperties) + { + Provider = provider; + } +}