diff --git a/DataverseGen.Cli/Program.cs b/DataverseGen.Cli/Program.cs index 1c36580..00ca035 100644 --- a/DataverseGen.Cli/Program.cs +++ b/DataverseGen.Cli/Program.cs @@ -96,12 +96,8 @@ private static void Main(string[] args) MappingEntity[] data = dataConverter.GetMappedEntities(); WriteLine(@"Finish Load data"); - CustomApiModel[] customApis = Array.Empty(); - if (config.TemplateEngine.Type.Equals("ts", StringComparison.InvariantCultureIgnoreCase)) - { - DataverseCustomApiConverter customApiConverter = new(dataverseConnector, config.CustomApis); - customApis = customApiConverter.GetCustomApis(); - } + DataverseCustomApiConverter customApiConverter = new(dataverseConnector, config.CustomApis); + CustomApiModel[] customApis = customApiConverter.GetCustomApis(); Context context = new() { diff --git a/DataverseGen.Core/Config/ConfigValidation.cs b/DataverseGen.Core/Config/ConfigValidation.cs index 95f29af..99c3dc7 100644 --- a/DataverseGen.Core/Config/ConfigValidation.cs +++ b/DataverseGen.Core/Config/ConfigValidation.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; namespace DataverseGen.Core.Config; @@ -12,20 +11,9 @@ public static void Validate(ConfigModel config) throw new ArgumentNullException(nameof(config)); } - if (!HasCustomApis(config.CustomApis)) + if (config.TemplateEngine == null) { - return; + throw new InvalidOperationException("TemplateEngine must be configured."); } - - if (config.TemplateEngine == null || - !string.Equals(config.TemplateEngine.Type, "ts", StringComparison.InvariantCultureIgnoreCase)) - { - throw new InvalidOperationException("CustomApis can only be used when TemplateEngine.Type is set to 'ts'."); - } - } - - private static bool HasCustomApis(string[] customApis) - { - return customApis != null && customApis.Any(api => !string.IsNullOrWhiteSpace(api)); } } diff --git a/DataverseGen.Core/CustomApi/CustomApiParameterModel.cs b/DataverseGen.Core/CustomApi/CustomApiParameterModel.cs index fd52169..6fde771 100644 --- a/DataverseGen.Core/CustomApi/CustomApiParameterModel.cs +++ b/DataverseGen.Core/CustomApi/CustomApiParameterModel.cs @@ -46,6 +46,9 @@ public class CustomApiParameterModel [JsonProperty] public string TypeScriptType { get; set; } + [JsonProperty] + public string CSharpTypeName { get; set; } + [JsonProperty] public string WebApiTypeName { get; set; } diff --git a/DataverseGen.Core/DataConverter/DataverseCustomApiConverter.cs b/DataverseGen.Core/DataConverter/DataverseCustomApiConverter.cs index 381b05f..887a25f 100644 --- a/DataverseGen.Core/DataConverter/DataverseCustomApiConverter.cs +++ b/DataverseGen.Core/DataConverter/DataverseCustomApiConverter.cs @@ -216,7 +216,12 @@ private static CustomApiParameterModel ParseParameter(Entity entity) string typeLabel = GetFormattedOrRawValue(entity, "type"); string logicalEntityName = GetString(entity, "logicalentityname"); - NormalizeCustomApiParameterType(typeLabel, logicalEntityName, out string tsType, out string webApiTypeName, out string structuralProperty); + NormalizeCustomApiParameterType(typeLabel, + logicalEntityName, + out string tsType, + out string cSharpTypeName, + out string webApiTypeName, + out string structuralProperty); return new CustomApiParameterModel { @@ -233,6 +238,7 @@ private static CustomApiParameterModel ParseParameter(Entity entity) RequestPropertyName = MetadataNamingExtensions.GetProperVariableName(string.IsNullOrWhiteSpace(uniqueName) ? propertyName : uniqueName), ConstructorParameterName = MetadataNamingExtensions.GetProperVariableName(string.IsNullOrWhiteSpace(displayName) ? propertyName : displayName), TypeScriptType = tsType, + CSharpTypeName = cSharpTypeName, WebApiTypeName = webApiTypeName, WebApiStructuralProperty = structuralProperty }; @@ -299,14 +305,27 @@ private static void NormalizeCustomApiParameterType( string typeLabel, string logicalEntityName, out string typeScriptType, + out string cSharpTypeName, out string webApiTypeName, out string structuralProperty) { - string normalizedType = typeLabel.Trim().ToLowerInvariant(); + string normalizedType = (typeLabel ?? string.Empty).Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(normalizedType)) + { + typeScriptType = "string"; + cSharpTypeName = "string"; + webApiTypeName = "Edm.String"; + structuralProperty = "WebApiRequestStructuralProperty.PrimitiveType"; + return; + } if (normalizedType.Contains("collection")) { typeScriptType = "string[]"; + cSharpTypeName = string.IsNullOrWhiteSpace(logicalEntityName) + ? "string[]" + : "EntityReference[]"; webApiTypeName = string.IsNullOrWhiteSpace(logicalEntityName) ? "Collection(Edm.String)" : $"Collection(mscrm.{logicalEntityName})"; @@ -317,6 +336,7 @@ private static void NormalizeCustomApiParameterType( if (normalizedType.Contains("entity")) { typeScriptType = "any"; + cSharpTypeName = "EntityReference"; webApiTypeName = string.IsNullOrWhiteSpace(logicalEntityName) ? "mscrm.crmbaseentity" : $"mscrm.{logicalEntityName}"; @@ -327,6 +347,7 @@ private static void NormalizeCustomApiParameterType( if (normalizedType.Contains("bool")) { typeScriptType = "boolean"; + cSharpTypeName = "bool"; webApiTypeName = "Edm.Boolean"; structuralProperty = "WebApiRequestStructuralProperty.PrimitiveType"; return; @@ -335,6 +356,7 @@ private static void NormalizeCustomApiParameterType( if (normalizedType.Contains("int") || normalizedType.Contains("whole")) { typeScriptType = "number"; + cSharpTypeName = "int"; webApiTypeName = "Edm.Int32"; structuralProperty = "WebApiRequestStructuralProperty.PrimitiveType"; return; @@ -343,6 +365,7 @@ private static void NormalizeCustomApiParameterType( if (normalizedType.Contains("decimal") || normalizedType.Contains("double") || normalizedType.Contains("money") || normalizedType.Contains("float")) { typeScriptType = "number"; + cSharpTypeName = "decimal"; webApiTypeName = "Edm.Decimal"; structuralProperty = "WebApiRequestStructuralProperty.PrimitiveType"; return; @@ -351,6 +374,7 @@ private static void NormalizeCustomApiParameterType( if (normalizedType.Contains("date")) { typeScriptType = "Date"; + cSharpTypeName = "DateTime"; webApiTypeName = "Edm.DateTimeOffset"; structuralProperty = "WebApiRequestStructuralProperty.PrimitiveType"; return; @@ -359,12 +383,14 @@ private static void NormalizeCustomApiParameterType( if (normalizedType.Contains("guid") || normalizedType.Contains("unique")) { typeScriptType = "string"; + cSharpTypeName = "Guid"; webApiTypeName = "Edm.Guid"; structuralProperty = "WebApiRequestStructuralProperty.PrimitiveType"; return; } typeScriptType = "string"; + cSharpTypeName = "string"; webApiTypeName = "Edm.String"; structuralProperty = "WebApiRequestStructuralProperty.PrimitiveType"; } diff --git a/DataverseGen.Core/Generators/CustomApiCSharpGenerator.cs b/DataverseGen.Core/Generators/CustomApiCSharpGenerator.cs new file mode 100644 index 0000000..8a548c2 --- /dev/null +++ b/DataverseGen.Core/Generators/CustomApiCSharpGenerator.cs @@ -0,0 +1,227 @@ +using System.Text; +using DataverseGen.Core.CustomApi; +using DataverseGen.Core.Extensions; + +namespace DataverseGen.Core.Generators; + +public static class CustomApiCSharpGenerator +{ + public static void WriteCustomApis(string outPath, string namespaceName, IEnumerable customApis) + { + if (customApis == null) + { + return; + } + + string customApiDirectory = Path.Combine(outPath, "customapi"); + Directory.CreateDirectory(customApiDirectory); + + foreach (CustomApiModel customApi in customApis) + { + string filePath = Path.Combine(customApiDirectory, $"{customApi.RequestClassName.ToLowerInvariant()}.cs"); + File.WriteAllText(filePath, RenderRequestClass(namespaceName, customApi), Encoding.UTF8); + } + } + + internal static string RenderRequestClass(string namespaceName, CustomApiModel customApi) + { + StringBuilder sb = new(); + string className = customApi.RequestClassName; + List constructorParameters = customApi.RequestParameters.ToList(); + CustomApiParameterModel boundParameter = GetBoundParameter(customApi); + + sb.AppendLine("using System;"); + sb.AppendLine("using Microsoft.Xrm.Sdk;"); + sb.AppendLine(); + sb.AppendLine($"namespace {namespaceName};"); + sb.AppendLine(); + sb.AppendLine($"public sealed class {className} : OrganizationRequest"); + sb.AppendLine("{"); + sb.AppendLine($"\tpublic const string RequestNameValue = \"{EscapeString(customApi.OperationName)}\";"); + sb.AppendLine(); + + if (constructorParameters.Count > 0 || boundParameter != null) + { + sb.Append($"\tpublic {className}("); + sb.Append(string.Join(", ", constructorParameters.Select(p => $"{GetCSharpType(p)} {GetParameterName(p)}"))); + + if (boundParameter != null) + { + if (constructorParameters.Count > 0) + { + sb.Append(", "); + } + + sb.Append($"EntityReference {GetParameterName(boundParameter)}"); + } + + sb.AppendLine(")"); + sb.AppendLine("\t{"); + + sb.AppendLine("\t\tRequestName = RequestNameValue;"); + + foreach (CustomApiParameterModel parameter in constructorParameters) + { + sb.AppendLine($"\t\t{GetPropertyName(parameter)} = {GetParameterName(parameter)};"); + } + + if (boundParameter != null) + { + sb.AppendLine($"\t\t{GetPropertyName(boundParameter)} = {GetParameterName(boundParameter)};"); + } + + sb.AppendLine("\t}"); + sb.AppendLine(); + } + else + { + sb.AppendLine($"\tpublic {className}()"); + sb.AppendLine("\t{"); + sb.AppendLine("\t\tRequestName = RequestNameValue;"); + sb.AppendLine("\t}"); + sb.AppendLine(); + } + + if (boundParameter != null) + { + sb.AppendLine($"\tpublic EntityReference {GetPropertyName(boundParameter)}"); + sb.AppendLine("\t{"); + sb.AppendLine($"\t\tget => Parameters.ContainsKey(\"{EscapeString(boundParameter.RequestPropertyName)}\")"); + sb.AppendLine($"\t\t\t? (EntityReference)Parameters[\"{EscapeString(boundParameter.RequestPropertyName)}\"]"); + sb.AppendLine("\t\t\t: default;"); + sb.AppendLine($"\t\tset => Parameters[\"{EscapeString(boundParameter.RequestPropertyName)}\"] = value;"); + sb.AppendLine("\t}"); + sb.AppendLine(); + } + + foreach (CustomApiParameterModel parameter in constructorParameters) + { + sb.AppendLine($"\tpublic {GetCSharpType(parameter)} {GetPropertyName(parameter)}"); + sb.AppendLine("\t{"); + sb.AppendLine($"\t\tget => Parameters.ContainsKey(\"{EscapeString(parameter.RequestPropertyName)}\")"); + sb.AppendLine($"\t\t\t? ({GetCSharpType(parameter)})Parameters[\"{EscapeString(parameter.RequestPropertyName)}\"]"); + sb.AppendLine("\t\t\t: default;"); + sb.AppendLine($"\t\tset => Parameters[\"{EscapeString(parameter.RequestPropertyName)}\"] = value;"); + sb.AppendLine("\t}"); + sb.AppendLine(); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static CustomApiParameterModel GetBoundParameter(CustomApiModel customApi) + { + if (string.IsNullOrWhiteSpace(customApi.BoundEntityLogicalName)) + { + return null; + } + + return new CustomApiParameterModel + { + DisplayName = "Entity", + UniqueName = "entity", + PropertyName = "entity", + RequestPropertyName = "entity", + ConstructorParameterName = "entity", + TypeScriptType = "any", + CSharpTypeName = "EntityReference", + WebApiTypeName = $"mscrm.{customApi.BoundEntityLogicalName}", + WebApiStructuralProperty = "WebApiRequestStructuralProperty.EntityType" + }; + } + + private static string GetPropertyName(CustomApiParameterModel parameter) + { + string source = string.IsNullOrWhiteSpace(parameter.DisplayName) + ? parameter.UniqueName + : parameter.DisplayName; + + string sanitized = MetadataNamingExtensions.GetProperVariableName(source); + if (sanitized.StartsWith("_", StringComparison.InvariantCulture) && + sanitized.Length > 1 && + char.IsDigit(sanitized[1])) + { + return sanitized; + } + + return ToPascalCase(sanitized); + } + + private static string GetParameterName(CustomApiParameterModel parameter) + { + string propertyName = GetPropertyName(parameter); + + if (string.IsNullOrWhiteSpace(propertyName)) + { + return "value"; + } + + string parameterName = char.ToLowerInvariant(propertyName[0]) + propertyName[1..]; + return IsCSharpKeyword(parameterName) + ? $"@{parameterName}" + : parameterName; + } + + private static string GetCSharpType(CustomApiParameterModel parameter) + { + return string.IsNullOrWhiteSpace(parameter.CSharpTypeName) + ? "string" + : parameter.CSharpTypeName; + } + + private static string EscapeString(string value) + { + return string.IsNullOrEmpty(value) + ? string.Empty + : value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private static string ToPascalCase(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + char[] separators = [' ', '_', '-', '.', '/', '\\']; + string[] parts = value.Split(separators, StringSplitOptions.RemoveEmptyEntries); + StringBuilder builder = new(); + + for (int i = 0; i < parts.Length; i++) + { + string part = parts[i].Trim(); + + if (part.Length == 0) + { + continue; + } + + builder.Append(char.ToUpperInvariant(part[0])); + if (part.Length > 1) + { + builder.Append(part[1..]); + } + } + + return builder.ToString(); + } + + private static bool IsCSharpKeyword(string value) + { + return value.Equals("public", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("private", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("single", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("new", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("partial", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("to", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("error", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("readonly", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("case", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("object", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("global", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("true", StringComparison.InvariantCultureIgnoreCase) + || value.Equals("false", StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/DataverseGen.Core/Generators/CustomApiTypeScriptGenerator.cs b/DataverseGen.Core/Generators/CustomApiTypeScriptGenerator.cs index 7b8dcaa..26086d7 100644 --- a/DataverseGen.Core/Generators/CustomApiTypeScriptGenerator.cs +++ b/DataverseGen.Core/Generators/CustomApiTypeScriptGenerator.cs @@ -27,7 +27,7 @@ internal static string RenderRequestClass(CustomApiModel customApi) StringBuilder sb = new(); string className = customApi.RequestClassName; List constructorParameters = customApi.RequestParameters.ToList(); - CustomApiParameterModel? boundParameter = GetBoundParameter(customApi); + CustomApiParameterModel boundParameter = GetBoundParameter(customApi); sb.AppendLine($"export default class {className} implements IWebApiRequest {{"); sb.AppendLine("\tprivate operationName: string;"); @@ -111,7 +111,7 @@ internal static string RenderRequestClass(CustomApiModel customApi) return sb.ToString(); } - private static CustomApiParameterModel? GetBoundParameter(CustomApiModel customApi) + private static CustomApiParameterModel GetBoundParameter(CustomApiModel customApi) { if (string.IsNullOrWhiteSpace(customApi.BoundEntityLogicalName)) { @@ -129,7 +129,7 @@ internal static string RenderRequestClass(CustomApiModel customApi) }; } - private static string GetBoundParameterName(CustomApiParameterModel? boundParameter) + private static string GetBoundParameterName(CustomApiParameterModel boundParameter) { return boundParameter == null ? "null" diff --git a/DataverseGen.Core/Generators/Scriban/ScribanGenerator.cs b/DataverseGen.Core/Generators/Scriban/ScribanGenerator.cs index 7463adb..5be3e97 100644 --- a/DataverseGen.Core/Generators/Scriban/ScribanGenerator.cs +++ b/DataverseGen.Core/Generators/Scriban/ScribanGenerator.cs @@ -56,6 +56,8 @@ public override void GenerateTemplate() $@"type {TemplateEngineModel.Type} not supported use C# or TS"); } + GenerateCustomApis(); + Console.WriteLine($@"Generating Scriban template '{TemplateName}' elapsed in: {stopper.Elapsed:g}"); stopper.Stop(); } @@ -123,13 +125,11 @@ private void RunTypeScriptGenerator() if (TemplateEngineModel.IsSingleOutput) { SingleFileTypescript(); - GenerateCustomApis(); return; } MultiOutputGeneratorTypeScript(); - GenerateCustomApis(); } private void SingleFileCSharp() @@ -229,7 +229,16 @@ private void GenerateCustomApis() return; } - CustomApiTypeScriptGenerator.WriteCustomApis(_fullOutputPath, Context.CustomApis); + switch (TemplateEngineModel.Type.ToLowerInvariant()) + { + case "c#": + CustomApiCSharpGenerator.WriteCustomApis(_fullOutputPath, Context.Namespace, Context.CustomApis); + break; + + case "ts": + CustomApiTypeScriptGenerator.WriteCustomApis(_fullOutputPath, Context.CustomApis); + break; + } } [GeneratedRegex("^\\s*$\\n|\\r", RegexOptions.Multiline)] diff --git a/README.md b/README.md index bb296b4..c3bd202 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ Dataverse early-bound generator for Power Platform, built as a .NET 7 CLI on top - The Scriban generator supports both `C#` and `ts` output types. - Generation can run in single-file or multi-file mode. - TypeScript generation also emits custom API request wrappers under `customapi/`. +- C# generation also emits custom API request wrappers under `customapi/`. - Built-in templates are available through `TemplateName = "Main"`, and project templates can be copied under a `Templates` folder. - Release builds are packaged into self-contained and framework-dependent ZIP archives by `build.ps1`. ## Current Behavior - The app loads Dataverse metadata for the entities listed in `Entities`. -- If `CustomApis` is set, TypeScript output is narrowed to the listed custom API unique names. -- `CustomApis` is only valid when `TemplateEngine.Type` is `ts`. +- If `CustomApis` is set, the custom API output is narrowed to the listed custom API unique names. - It validates the connection string when `EnableConnectionStringValidation` is enabled. - It can throw when an entity from `Entities` is missing if `ThrowOnEntityNotFound` is enabled. - `TemplateEngine.Name` currently supports `scriban`. @@ -87,18 +87,18 @@ dotnet run --project DataverseGen.Cli } ``` -## Custom API for TypeScript +## Custom API -Custom API request wrappers are generated automatically for TypeScript output. +Custom API request wrappers are generated automatically for both `C#` and `ts` output. Configuration is minimal: -- set `TemplateEngine.Type` to `ts` +- set `TemplateEngine.Type` to `C#` or `ts` - keep the normal Dataverse connection string and output directory configuration - optionally set `CustomApis` to a list of custom API unique names to generate - make sure the environment contains `customapi`, `customapirequestparameter`, and `customapiresponseproperty` metadata No extra flag is required in `dataversegen.config.json` and there is no separate custom API switch. -The generator reads the Dataverse metadata and writes TypeScript wrappers into: +The generator reads the Dataverse metadata and writes custom API wrappers into: ```text /customapi/ @@ -130,7 +130,7 @@ Example TypeScript config that enables the feature: } ``` -The generated wrapper follows the Web API request contract and uses: +The generated wrappers use: - the custom API unique name as the operation name - request parameter names from Dataverse metadata @@ -138,11 +138,9 @@ The generated wrapper follows the Web API request contract and uses: If `CustomApis` is omitted or empty, all available custom APIs are generated. -If you only generate `C#`, custom API wrappers are not emitted. - ### Filtering custom APIs -Use `CustomApis` to limit TypeScript generation to specific custom API unique names: +Use `CustomApis` to limit custom API generation to specific `uniquename` values: ```json { @@ -158,7 +156,7 @@ The names are matched against the Dataverse `uniquename` field, case-insensitive ## Configuration Fields - `Entities` - Dataverse schema names to generate. -- `CustomApis` - Custom API unique names to generate. If omitted or empty, all custom APIs are generated for TypeScript output. Requires `TemplateEngine.Type = ts`. +- `CustomApis` - Custom API unique names to generate. If omitted or empty, all custom APIs are generated for the selected output type. - `ConnectionString` - Dataverse connection string. If omitted, the CLI asks for it and stores it in Windows Credential Manager. - `Namespace` - Namespace used in generated code. - `OutDirectory` - Output folder for generated files. diff --git a/Tests/DataverseGen.Core.Tests/Config/ConfigModelTests.cs b/Tests/DataverseGen.Core.Tests/Config/ConfigModelTests.cs index 93dc1ea..051eeb8 100644 --- a/Tests/DataverseGen.Core.Tests/Config/ConfigModelTests.cs +++ b/Tests/DataverseGen.Core.Tests/Config/ConfigModelTests.cs @@ -73,7 +73,7 @@ public void TemplateEngineModel_DefaultValues_AreFalseOrNull() } [TestMethod] - public void ConfigValidation_ThrowsWhenCustomApisUsedWithNonTsOutput() + public void ConfigValidation_AllowsCustomApisForCsOutput() { var model = new ConfigModel { @@ -84,6 +84,20 @@ public void ConfigValidation_ThrowsWhenCustomApisUsedWithNonTsOutput() } }; + ConfigValidation.Validate(model); + } + + [TestMethod] + public void ConfigValidation_ThrowsWhenConfigIsNull() + { + Assert.ThrowsException(() => ConfigValidation.Validate(null)); + } + + [TestMethod] + public void ConfigValidation_ThrowsWhenTemplateEngineIsMissing() + { + var model = new ConfigModel(); + Assert.ThrowsException(() => ConfigValidation.Validate(model)); } diff --git a/Tests/DataverseGen.Core.Tests/DataConverter/DataverseCustomApiConverterTests.cs b/Tests/DataverseGen.Core.Tests/DataConverter/DataverseCustomApiConverterTests.cs index eef97c4..a888a98 100644 --- a/Tests/DataverseGen.Core.Tests/DataConverter/DataverseCustomApiConverterTests.cs +++ b/Tests/DataverseGen.Core.Tests/DataConverter/DataverseCustomApiConverterTests.cs @@ -1,5 +1,6 @@ using DataverseGen.Core.DataConverter; using Microsoft.Xrm.Sdk; +using System.Reflection; namespace DataverseGen.Core.Tests.DataConverter; @@ -38,6 +39,75 @@ public void FilterCustomApiEntities_KeepsSelectionOrder() Assert.AreEqual(first.Id, filtered[1].Id); } + [TestMethod] + public void FilterCustomApiEntities_ReturnsAllWhenSelectionIsEmpty() + { + Entity first = CreateCustomApiEntity("11111111-1111-1111-1111-111111111111", "newfirstapi"); + Entity second = CreateCustomApiEntity("22222222-2222-2222-2222-222222222222", "newsecondapi"); + + List filtered = DataverseCustomApiConverter.FilterCustomApiEntities( + new[] { first, second }, + Array.Empty()); + + Assert.AreEqual(2, filtered.Count); + Assert.AreEqual(first.Id, filtered[0].Id); + Assert.AreEqual(second.Id, filtered[1].Id); + } + + [TestMethod] + public void FilterCustomApiEntities_IgnoresDuplicateAndBlankSelections() + { + Entity first = CreateCustomApiEntity("11111111-1111-1111-1111-111111111111", "newfirstapi"); + Entity second = CreateCustomApiEntity("22222222-2222-2222-2222-222222222222", "newsecondapi"); + + List filtered = DataverseCustomApiConverter.FilterCustomApiEntities( + new[] { first, second }, + new[] { " ", "newsecondapi", "NEWSECONDAPI", null! }); + + Assert.AreEqual(1, filtered.Count); + Assert.AreEqual(second.Id, filtered[0].Id); + } + + [DataTestMethod] + [DataRow("Collection of strings", "", "string[]", "string[]", "Collection(Edm.String)", "WebApiRequestStructuralProperty.Collection")] + [DataRow("Entity", "account", "any", "EntityReference", "mscrm.account", "WebApiRequestStructuralProperty.EntityType")] + [DataRow("Whole Number", "", "number", "int", "Edm.Int32", "WebApiRequestStructuralProperty.PrimitiveType")] + [DataRow("Date and Time", "", "Date", "DateTime", "Edm.DateTimeOffset", "WebApiRequestStructuralProperty.PrimitiveType")] + [DataRow("", "", "string", "string", "Edm.String", "WebApiRequestStructuralProperty.PrimitiveType")] + [DataRow(null!, "", "string", "string", "Edm.String", "WebApiRequestStructuralProperty.PrimitiveType")] + [DataRow("Unknown Type", "", "string", "string", "Edm.String", "WebApiRequestStructuralProperty.PrimitiveType")] + public void NormalizeCustomApiParameterType_MapsCommonTypes( + string typeLabel, + string logicalEntityName, + string expectedTypeScriptType, + string expectedCSharpTypeName, + string expectedWebApiTypeName, + string expectedStructuralProperty) + { + MethodInfo? method = typeof(DataverseCustomApiConverter).GetMethod( + "NormalizeCustomApiParameterType", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(method); + + object[] parameters = + { + typeLabel, + logicalEntityName, + null!, + null!, + null!, + null! + }; + + method!.Invoke(null, parameters); + + Assert.AreEqual(expectedTypeScriptType, parameters[2]); + Assert.AreEqual(expectedCSharpTypeName, parameters[3]); + Assert.AreEqual(expectedWebApiTypeName, parameters[4]); + Assert.AreEqual(expectedStructuralProperty, parameters[5]); + } + private static Entity CreateCustomApiEntity(string id, string uniqueName) { Entity entity = new("customapi") diff --git a/Tests/DataverseGen.Core.Tests/Generators/CustomApiCSharpGeneratorTests.cs b/Tests/DataverseGen.Core.Tests/Generators/CustomApiCSharpGeneratorTests.cs new file mode 100644 index 0000000..bb2910f --- /dev/null +++ b/Tests/DataverseGen.Core.Tests/Generators/CustomApiCSharpGeneratorTests.cs @@ -0,0 +1,218 @@ +using DataverseGen.Core.CustomApi; +using DataverseGen.Core.Generators; + +namespace DataverseGen.Core.Tests.Generators; + +[TestClass] +public class CustomApiCSharpGeneratorTests +{ + [TestMethod] + public void WriteCustomApis_CreatesCSharpRequestClass() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiCSharpGenerator.WriteCustomApis(tempRoot, + "MyApp.CustomApis", + new[] + { + new CustomApiModel + { + RequestClassName = "ProcessStatusRequest", + OperationName = "newupdatestatus", + RequestParameters = new[] + { + new CustomApiParameterModel + { + DisplayName = "Customer Id", + UniqueName = "customerid", + PropertyName = "customerid", + RequestPropertyName = "customerid", + ConstructorParameterName = "customerid", + CSharpTypeName = "string" + } + } + } + }); + + string filePath = Path.Combine(tempRoot, "customapi", "processstatusrequest.cs"); + string content = File.ReadAllText(filePath); + + StringAssert.Contains(content, "namespace MyApp.CustomApis;"); + StringAssert.Contains(content, "public sealed class ProcessStatusRequest : OrganizationRequest"); + StringAssert.Contains(content, "public const string RequestNameValue = \"newupdatestatus\";"); + StringAssert.Contains(content, "public string CustomerId"); + StringAssert.Contains(content, "RequestName = RequestNameValue;"); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [TestMethod] + public void WriteCustomApis_IncludesBoundParameterAndMultipleProperties() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiCSharpGenerator.WriteCustomApis(tempRoot, + "MyApp.CustomApis", + new[] + { + new CustomApiModel + { + RequestClassName = "ProcessStatusRequest", + OperationName = "newupdatestatus", + BoundEntityLogicalName = "account", + RequestParameters = new[] + { + new CustomApiParameterModel + { + DisplayName = "Flag", + UniqueName = "flag", + PropertyName = "flag", + RequestPropertyName = "flag", + ConstructorParameterName = "flag", + CSharpTypeName = "bool" + }, + new CustomApiParameterModel + { + DisplayName = "Count", + UniqueName = "count", + PropertyName = "count", + RequestPropertyName = "count", + ConstructorParameterName = "count", + CSharpTypeName = "int" + } + } + } + }); + + string filePath = Path.Combine(tempRoot, "customapi", "processstatusrequest.cs"); + string content = File.ReadAllText(filePath); + + StringAssert.Contains(content, "public EntityReference Entity"); + StringAssert.Contains(content, "public ProcessStatusRequest(bool flag, int count, EntityReference entity)"); + StringAssert.Contains(content, "public bool Flag"); + StringAssert.Contains(content, "public int Count"); + StringAssert.Contains(content, "Parameters[\"entity\"] = value;"); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [TestMethod] + public void WriteCustomApis_CreatesParameterlessRequestClass() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiCSharpGenerator.WriteCustomApis(tempRoot, + "MyApp.CustomApis", + new[] + { + new CustomApiModel + { + RequestClassName = "PingRequest", + OperationName = "newping" + } + }); + + string filePath = Path.Combine(tempRoot, "customapi", "pingrequest.cs"); + string content = File.ReadAllText(filePath); + + StringAssert.Contains(content, "public PingRequest()"); + StringAssert.Contains(content, "RequestName = RequestNameValue;"); + Assert.IsFalse(content.Contains("public EntityReference Entity")); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [TestMethod] + public void WriteCustomApis_EscapesCSharpKeywordParameters() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiCSharpGenerator.WriteCustomApis(tempRoot, + "MyApp.CustomApis", + new[] + { + new CustomApiModel + { + RequestClassName = "KeywordRequest", + OperationName = "newkeyword", + RequestParameters = new[] + { + new CustomApiParameterModel + { + DisplayName = "Private", + UniqueName = "private", + PropertyName = "private", + RequestPropertyName = "private", + ConstructorParameterName = "private", + CSharpTypeName = "string" + } + } + } + }); + + string filePath = Path.Combine(tempRoot, "customapi", "keywordrequest.cs"); + string content = File.ReadAllText(filePath); + + StringAssert.Contains(content, "public string Private"); + StringAssert.Contains(content, "public KeywordRequest(string @private)"); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [TestMethod] + public void WriteCustomApis_ReturnsWhenEnumerableIsNull() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiCSharpGenerator.WriteCustomApis(tempRoot, "MyApp.CustomApis", (IEnumerable)null!); + + Assert.IsFalse(Directory.Exists(Path.Combine(tempRoot, "customapi"))); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } +} diff --git a/Tests/DataverseGen.Core.Tests/Generators/CustomApiTypeScriptGeneratorTests.cs b/Tests/DataverseGen.Core.Tests/Generators/CustomApiTypeScriptGeneratorTests.cs index a284690..f598432 100644 --- a/Tests/DataverseGen.Core.Tests/Generators/CustomApiTypeScriptGeneratorTests.cs +++ b/Tests/DataverseGen.Core.Tests/Generators/CustomApiTypeScriptGeneratorTests.cs @@ -55,4 +55,94 @@ public void WriteCustomApis_CreatesTypeScriptRequestClass() } } } + + [TestMethod] + public void WriteCustomApis_ReturnsWhenEnumerableIsNull() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiTypeScriptGenerator.WriteCustomApis(tempRoot, (IEnumerable)null!); + + Assert.IsFalse(Directory.Exists(Path.Combine(tempRoot, "customapi"))); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [TestMethod] + public void WriteCustomApis_CreatesFunctionRequestClassWithoutParameters() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiTypeScriptGenerator.WriteCustomApis(tempRoot, new[] + { + new CustomApiModel + { + RequestClassName = "GetStatusRequest", + OperationName = "newgetstatus", + IsFunction = true + } + }); + + string filePath = Path.Combine(tempRoot, "customapi", "getstatusrequest.ts"); + string content = File.ReadAllText(filePath); + + StringAssert.Contains(content, "constructor()"); + StringAssert.Contains(content, "boundParameter: null"); + StringAssert.Contains(content, "operationType: WebApiRequestOperationType.Function"); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + [TestMethod] + public void WriteCustomApis_IncludesBoundParameter() + { + string tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + CustomApiTypeScriptGenerator.WriteCustomApis(tempRoot, new[] + { + new CustomApiModel + { + RequestClassName = "BoundRequest", + OperationName = "newbound", + BoundEntityLogicalName = "account" + } + }); + + string filePath = Path.Combine(tempRoot, "customapi", "boundrequest.ts"); + string content = File.ReadAllText(filePath); + + StringAssert.Contains(content, "private entity: any;"); + StringAssert.Contains(content, "constructor(entity: any)"); + StringAssert.Contains(content, "boundParameter: \"entity\""); + StringAssert.Contains(content, "entity: this.entity"); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } } diff --git a/Tests/DataverseGen.Core.Tests/Metadata/CrmAttributeExtensionsTests.cs b/Tests/DataverseGen.Core.Tests/Metadata/CrmAttributeExtensionsTests.cs index 1ac07fd..ec4dc07 100644 --- a/Tests/DataverseGen.Core.Tests/Metadata/CrmAttributeExtensionsTests.cs +++ b/Tests/DataverseGen.Core.Tests/Metadata/CrmAttributeExtensionsTests.cs @@ -8,7 +8,7 @@ public class CrmAttributeExtensionsTests [AttributeUsage(AttributeTargets.All)] private sealed class SampleAttribute : Attribute { - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public bool IsActive { get; set; } public int Priority { get; set; } } @@ -16,7 +16,7 @@ private sealed class SampleAttribute : Attribute [AttributeUsage(AttributeTargets.All)] private sealed class NoSuffixAttr : Attribute { - public string Value { get; set; } + public string Value { get; set; } = string.Empty; } // ── Type name formatting ───────────────────────────────────────────────────