From cc4b052a0f9412058121e9e6ba489f4ca86149d7 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Fri, 10 Jul 2026 15:25:37 +0200 Subject: [PATCH] feat(env): add customapi group with list, create, generate-openapi --- .../CustomApi/CustomApiCliCommand.cs | 20 ++ .../CustomApi/CustomApiCreateCliCommand.cs | 193 ++++++++++++++++++ .../CustomApiGenerateOpenApiCliCommand.cs | 142 +++++++++++++ .../CustomApi/CustomApiListCliCommand.cs | 123 +++++++++++ .../CustomApi/CustomApiMaps.cs | 106 ++++++++++ .../CustomApi/CustomApiOpenApiBuilder.cs | 173 ++++++++++++++++ .../EnvironmentCliCommand.cs | 2 +- .../ChangesetApplier.cs | 56 ++++- .../CustomApi/CustomApiMapsTests.cs | 83 ++++++++ .../CustomApi/CustomApiOpenApiBuilderTests.cs | 86 ++++++++ 10 files changed, 973 insertions(+), 11 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCreateCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiGenerateOpenApiCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiListCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiMaps.cs create mode 100644 src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiOpenApiBuilder.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiMapsTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiOpenApiBuilderTests.cs diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCliCommand.cs new file mode 100644 index 00000000..14497f88 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCliCommand.cs @@ -0,0 +1,20 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.CustomApi; + +/// +/// Parent command for Custom API operations. +/// Usage: txc environment customapi [list|create|generate-openapi] +/// +[CliCommand( + Name = "customapi", + Description = "Custom API discovery, creation, and OpenAPI generation for the live environment.", + Children = new[] { typeof(CustomApiListCliCommand), typeof(CustomApiCreateCliCommand), typeof(CustomApiGenerateOpenApiCliCommand) } +)] +public class CustomApiCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCreateCliCommand.cs new file mode 100644 index 00000000..f7ee770d --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiCreateCliCommand.cs @@ -0,0 +1,193 @@ +using System.ComponentModel; +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.CustomApi; + +/// +/// Creates a Custom API with optional request parameters and response properties. +/// Usage: txc environment customapi create --unique-name <name> --display-name <label> [--request-param name:type[:optional] ...] [--response-property name:type ...] --apply +/// +[CliIdempotent] +[CliCommand( + Name = "create", + Description = "Create a Custom API in the LIVE connected environment, optionally with request parameters and response properties. Requires an active profile. Parameter types: boolean, datetime, decimal, entity, entitycollection, entityreference, float, integer, money, picklist, string, stringarray, guid." +)] +#pragma warning disable TXC003 +public class CustomApiCreateCliCommand : StagedCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CustomApiCreateCliCommand)); + + [CliOption(Name = "--unique-name", Description = "Unique name of the Custom API, including publisher prefix (e.g. udpp_CalculateTotal).", Required = true)] + public string UniqueName { get; set; } = null!; + + [CliOption(Name = "--display-name", Description = "Display name (label) for the Custom API.", Required = true)] + public string DisplayName { get; set; } = null!; + + [CliOption(Name = "--description", Description = "Description of what the Custom API does. Defaults to the display name (Dataverse requires a non-empty description).", Required = false)] + public string? Description { get; set; } + + [CliOption(Name = "--binding-type", Description = "Binding: 'global' (default), 'entity', or 'entitycollection'.", Required = false)] + [DefaultValue("global")] + public string BindingType { get; set; } = "global"; + + [CliOption(Name = "--bound-entity", Description = "Logical name of the bound entity. Required when --binding-type is 'entity' or 'entitycollection'.", Required = false)] + public string? BoundEntity { get; set; } + + [CliOption(Name = "--function", Description = "Register as an OData function (GET, no side effects) instead of an action (POST).", Required = false)] + [DefaultValue(false)] + public bool IsFunction { get; set; } + + [CliOption(Name = "--private", Description = "Mark the Custom API as private (hidden from metadata consumers).", Required = false)] + [DefaultValue(false)] + public bool IsPrivate { get; set; } + + [CliOption(Name = "--execute-privilege", Description = "Name of the privilege required to execute the Custom API.", Required = false)] + public string? ExecutePrivilege { get; set; } + + [CliOption(Name = "--processing-step-type", Description = "Allowed custom processing steps: 'none' (default), 'async', or 'sync-and-async'.", Required = false)] + [DefaultValue("none")] + public string ProcessingStepType { get; set; } = "none"; + + [CliOption(Name = "--request-param", Description = "Request parameter as name:type[:optional] (e.g. Quantity:integer, Comment:string:optional). Repeatable.", Required = false)] + public string[]? RequestParams { get; set; } + + [CliOption(Name = "--response-property", Description = "Response property as name:type (e.g. Total:money). Repeatable.", Required = false)] + public string[]? ResponseProperties { get; set; } + + protected override async Task ExecuteAsync() + { + ValidateExecutionMode(); + + if (!CustomApiMaps.BindingTypes.TryGetValue(BindingType, out int bindingCode)) + { + Logger.LogError("Invalid --binding-type '{BindingType}'. Valid values: global, entity, entitycollection.", BindingType); + return ExitValidationError; + } + + if (bindingCode != 0 && string.IsNullOrWhiteSpace(BoundEntity)) + { + Logger.LogError("--bound-entity is required when --binding-type is '{BindingType}'.", BindingType); + return ExitValidationError; + } + + if (!CustomApiMaps.ProcessingStepTypes.TryGetValue(ProcessingStepType, out int stepTypeCode)) + { + Logger.LogError("Invalid --processing-step-type '{StepType}'. Valid values: none, async, sync-and-async.", ProcessingStepType); + return ExitValidationError; + } + + if (!TryParseSpecs(RequestParams, out var requestParams) || + !TryParseSpecs(ResponseProperties, out var responseProps)) + { + return ExitValidationError; + } + + if (Stage) + { + if (requestParams.Count > 0 || responseProps.Count > 0) + { + Logger.LogError("--request-param and --response-property require --apply; staged creation supports only the Custom API record itself."); + return ExitValidationError; + } + + var store = TxcServices.Get(); + store.Add(new StagedOperation + { + Category = "data", + OperationType = "CREATE", + TargetType = "record", + TargetDescription = "customapi", + Details = $"unique name: \"{UniqueName}\"", + Parameters = new Dictionary + { + ["entity"] = "customapi", + ["data"] = JsonSerializer.Serialize(BuildApiAttributes(bindingCode, stepTypeCode)), + ["file"] = null + } + }); + OutputWriter.WriteLine($"Staged: CREATE customapi '{UniqueName}'"); + return ExitSuccess; + } + + var service = TxcServices.Get(); + var apiAttributes = ToJsonElement(BuildApiAttributes(bindingCode, stepTypeCode)); + var apiId = await service.CreateAsync(Profile, "customapi", apiAttributes, CancellationToken.None).ConfigureAwait(false); + + foreach (var (name, typeCode, optional) in requestParams) + { + var attributes = ToJsonElement(BuildChildAttributes(apiId, name, typeCode, isOptional: optional)); + await service.CreateAsync(Profile, "customapirequestparameter", attributes, CancellationToken.None).ConfigureAwait(false); + } + + foreach (var (name, typeCode, _) in responseProps) + { + var attributes = ToJsonElement(BuildChildAttributes(apiId, name, typeCode, isOptional: null)); + await service.CreateAsync(Profile, "customapiresponseproperty", attributes, CancellationToken.None).ConfigureAwait(false); + } + + OutputFormatter.WriteResult( + "succeeded", + $"Created Custom API '{UniqueName}' with {requestParams.Count} request parameter(s) and {responseProps.Count} response property(ies).", + apiId.ToString()); + return ExitSuccess; + } + + private bool TryParseSpecs(string[]? specs, out List<(string Name, int TypeCode, bool Optional)> parsed) + { + parsed = []; + foreach (var spec in specs ?? []) + { + var result = CustomApiMaps.ParseParameterSpec(spec, out var error); + if (result is null) + { + Logger.LogError("{Error}", error); + return false; + } + parsed.Add(result.Value); + } + return true; + } + + private Dictionary BuildApiAttributes(int bindingCode, int stepTypeCode) + { + var attributes = new Dictionary + { + ["uniquename"] = UniqueName, + ["name"] = DisplayName, + ["displayname"] = DisplayName, + // Dataverse's RequiredFieldValidator rejects a NULL description on customapi. + ["description"] = string.IsNullOrWhiteSpace(Description) ? DisplayName : Description, + ["bindingtype"] = bindingCode, + ["isfunction"] = IsFunction, + ["isprivate"] = IsPrivate, + ["allowedcustomprocessingsteptype"] = stepTypeCode, + }; + if (bindingCode != 0) attributes["boundentitylogicalname"] = BoundEntity; + if (!string.IsNullOrWhiteSpace(ExecutePrivilege)) attributes["executeprivilegename"] = ExecutePrivilege; + return attributes; + } + + private static Dictionary BuildChildAttributes(Guid apiId, string name, int typeCode, bool? isOptional) + { + var attributes = new Dictionary + { + ["uniquename"] = name, + ["name"] = name, + ["displayname"] = name, + ["description"] = name, + ["type"] = typeCode, + ["customapiid"] = new Dictionary { ["Id"] = apiId, ["LogicalName"] = "customapi" }, + }; + if (isOptional is not null) attributes["isoptional"] = isOptional; + return attributes; + } + + private static JsonElement ToJsonElement(Dictionary attributes) => + JsonSerializer.SerializeToElement(attributes); +} diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiGenerateOpenApiCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiGenerateOpenApiCliCommand.cs new file mode 100644 index 00000000..6e6c9035 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiGenerateOpenApiCliCommand.cs @@ -0,0 +1,142 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.CustomApi; + +/// +/// Generates an OpenAPI 3.0 specification for Custom APIs in the connected environment. +/// Usage: txc environment customapi generate-openapi [--unique-name <name>] [--output <file>] +/// +[CliReadOnly] +[CliCommand( + Name = "generate-openapi", + Description = "Generate an OpenAPI 3.0 spec (JSON) describing Custom APIs in the LIVE connected environment, including request parameters and response properties. Requires an active profile. Use --unique-name for a single API, --output to write to a file instead of stdout." +)] +public class CustomApiGenerateOpenApiCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CustomApiGenerateOpenApiCliCommand)); + + [CliOption(Name = "--unique-name", Description = "Generate the spec for a single Custom API by unique name. Omit to include all.", Required = false)] + public string? UniqueName { get; set; } + + [CliOption(Name = "--output", Description = "Path of the file to write the spec to. Omit to print to stdout.", Required = false)] + public string? Output { get; set; } + + [CliOption(Name = "--title", Description = "OpenAPI document title.", Required = false)] + public string? Title { get; set; } + + [CliOption(Name = "--spec-version", Description = "OpenAPI document version string (info.version).", Required = false)] + public string? SpecVersion { get; set; } + + protected override async Task ExecuteAsync() + { + var query = TxcServices.Get(); + var ct = CancellationToken.None; + + string? apiFilter = UniqueName is not null ? $"uniquename eq '{UniqueName.Replace("'", "''")}'" : null; + var apiResult = await query.QueryODataAsync( + Profile, "customapis", + "customapiid,uniquename,name,description,bindingtype,boundentitylogicalname,isfunction", + apiFilter, "uniquename", null, false, ct).ConfigureAwait(false); + + if (apiResult.Records.Count == 0) + { + Logger.LogError(UniqueName is not null + ? $"Custom API '{UniqueName}' was not found in the environment." + : "No Custom APIs found in the environment."); + return ExitValidationError; + } + + var requestParams = await query.QueryODataAsync( + Profile, "customapirequestparameters", + "uniquename,name,type,isoptional,_customapiid_value", + null, "uniquename", null, false, ct).ConfigureAwait(false); + + var responseProps = await query.QueryODataAsync( + Profile, "customapiresponseproperties", + "uniquename,name,type,_customapiid_value", + null, "uniquename", null, false, ct).ConfigureAwait(false); + + var definitions = BuildDefinitions(apiResult.Records, requestParams.Records, responseProps.Records); + + var document = CustomApiOpenApiBuilder.Build( + definitions, + Title ?? "Dataverse Custom APIs", + SpecVersion ?? "1.0.0", + await TryResolveEnvironmentUrlAsync(ct).ConfigureAwait(false)); + + // Serialize via JsonNode so dictionary keys (parameter names, paths) keep their exact casing. + string json = JsonSerializer.SerializeToNode(document)!.ToJsonString(TxcOutputJsonOptions.Default); + + if (Output is not null) + { + var fullPath = Path.GetFullPath(Output); + await File.WriteAllTextAsync(fullPath, json, ct).ConfigureAwait(false); + OutputFormatter.WriteResult("succeeded", $"OpenAPI spec with {definitions.Count} Custom API(s) written to {fullPath}."); + } + else + { + OutputFormatter.WriteRaw(json); + } + + return ExitSuccess; + } + + internal static List BuildDefinitions( + IReadOnlyList apis, + IReadOnlyList requestParams, + IReadOnlyList responseProps) + { + var paramsByApi = requestParams.ToLookup(p => GetString(p, "_customapiid_value")); + var propsByApi = responseProps.ToLookup(p => GetString(p, "_customapiid_value")); + + return apis.Select(api => + { + string? id = GetString(api, "customapiid"); + return new CustomApiDefinition( + GetString(api, "uniquename") ?? "", + GetString(api, "name"), + GetString(api, "description"), + GetInt(api, "bindingtype"), + GetString(api, "boundentitylogicalname"), + GetBool(api, "isfunction"), + paramsByApi[id].Select(ToParameter).OrderBy(p => p.UniqueName, StringComparer.OrdinalIgnoreCase).ToList(), + propsByApi[id].Select(ToParameter).OrderBy(p => p.UniqueName, StringComparer.OrdinalIgnoreCase).ToList()); + }).ToList(); + } + + private static CustomApiParameter ToParameter(JsonElement e) => new( + GetString(e, "uniquename") ?? "", + GetString(e, "name"), + GetInt(e, "type"), + GetBool(e, "isoptional")); + + private async Task TryResolveEnvironmentUrlAsync(CancellationToken ct) + { + try + { + var resolver = TxcServices.Get(); + var context = await resolver.ResolveAsync(Profile, ct).ConfigureAwait(false); + return context.Connection.EnvironmentUrl; + } + catch (Exception) + { + return null; + } + } + + private static string? GetString(JsonElement e, string name) => + e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; + + private static int GetInt(JsonElement e, string name) => + e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0; + + private static bool GetBool(JsonElement e, string name) => + e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.True; +} diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiListCliCommand.cs new file mode 100644 index 00000000..431005b5 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiListCliCommand.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.CustomApi; + +/// +/// Summary row for a Custom API in the connected environment. +/// +public sealed record CustomApiSummaryRecord( + string UniqueName, + string? DisplayName, + string BindingType, + string? BoundEntity, + bool IsFunction, + bool IsPrivate, + Guid Id); + +/// +/// Lists Custom APIs registered in the connected Dataverse environment. +/// Usage: txc environment customapi list [--search <term>] +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "Lists Custom APIs registered in the LIVE connected environment. Requires an active profile. Use --search to filter by unique name or display name." +)] +public class CustomApiListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CustomApiListCliCommand)); + + [CliOption(Name = "--search", Description = "Filter Custom APIs by unique name or display name (case-insensitive substring).", Required = false)] + public string? Search { get; set; } + + protected override async Task ExecuteAsync() + { + var query = TxcServices.Get(); + var result = await query.QueryODataAsync( + Profile, + "customapis", + "customapiid,uniquename,name,bindingtype,boundentitylogicalname,isfunction,isprivate", + null, + "uniquename", + null, + false, + CancellationToken.None).ConfigureAwait(false); + + var rows = result.Records.Select(ToSummary) + .Where(r => MatchesSearch(r, Search)) + .ToList(); + + OutputFormatter.WriteList(rows, PrintTable); + return ExitSuccess; + } + + internal static CustomApiSummaryRecord ToSummary(JsonElement record) => new( + GetString(record, "uniquename") ?? "", + GetString(record, "name"), + CustomApiMaps.BindingTypeName(GetInt(record, "bindingtype")), + GetString(record, "boundentitylogicalname"), + GetBool(record, "isfunction"), + GetBool(record, "isprivate"), + record.TryGetProperty("customapiid", out var id) && id.TryGetGuid(out var guid) ? guid : Guid.Empty); + + internal static bool MatchesSearch(CustomApiSummaryRecord row, string? search) + { + if (string.IsNullOrWhiteSpace(search)) return true; + return row.UniqueName.Contains(search, StringComparison.OrdinalIgnoreCase) + || (row.DisplayName?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false); + } + + private static string? GetString(JsonElement e, string name) => + e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; + + private static int GetInt(JsonElement e, string name) => + e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0; + + private static bool GetBool(JsonElement e, string name) => + e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.True; + + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList rows) + { + if (rows.Count == 0) + { + OutputWriter.WriteLine("No Custom APIs found."); + return; + } + + int uniqueWidth = Math.Clamp(rows.Max(r => r.UniqueName.Length), 11, 48); + int displayWidth = Math.Clamp(rows.Max(r => (r.DisplayName ?? "").Length), 12, 40); + int bindingWidth = Math.Clamp(rows.Max(r => r.BindingType.Length), 7, 16); + int boundWidth = Math.Clamp(rows.Max(r => (r.BoundEntity ?? "").Length), 12, 32); + + string header = + $"{"Unique Name".PadRight(uniqueWidth)} | " + + $"{"Display Name".PadRight(displayWidth)} | " + + $"{"Binding".PadRight(bindingWidth)} | " + + $"{"Bound Entity".PadRight(boundWidth)} | " + + $"{"Function".PadRight(8)} | Private"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var r in rows) + { + OutputWriter.WriteLine( + $"{Truncate(r.UniqueName, uniqueWidth).PadRight(uniqueWidth)} | " + + $"{Truncate(r.DisplayName ?? "", displayWidth).PadRight(displayWidth)} | " + + $"{r.BindingType.PadRight(bindingWidth)} | " + + $"{Truncate(r.BoundEntity ?? "", boundWidth).PadRight(boundWidth)} | " + + $"{(r.IsFunction ? "true" : "false").PadRight(8)} | {(r.IsPrivate ? "true" : "false")}"); + } + } +#pragma warning restore TXC003 + + private static string Truncate(string value, int maxWidth) => + value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; +} diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiMaps.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiMaps.cs new file mode 100644 index 00000000..a12fca64 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiMaps.cs @@ -0,0 +1,106 @@ +namespace TALXIS.CLI.Features.Environment.CustomApi; + +/// +/// Value maps for Custom API metadata: binding types, parameter/property +/// type codes, and their OpenAPI schema equivalents. +/// +internal static class CustomApiMaps +{ + internal static readonly IReadOnlyDictionary BindingTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["global"] = 0, + ["entity"] = 1, + ["entitycollection"] = 2, + }; + + internal static readonly IReadOnlyDictionary ProcessingStepTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["none"] = 0, + ["async"] = 1, + ["sync-and-async"] = 2, + }; + + internal static readonly IReadOnlyDictionary ParameterTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["boolean"] = 0, + ["datetime"] = 1, + ["decimal"] = 2, + ["entity"] = 3, + ["entitycollection"] = 4, + ["entityreference"] = 5, + ["float"] = 6, + ["integer"] = 7, + ["money"] = 8, + ["picklist"] = 9, + ["string"] = 10, + ["stringarray"] = 11, + ["guid"] = 12, + }; + + internal static string BindingTypeName(int code) => code switch + { + 0 => "global", + 1 => "entity", + 2 => "entitycollection", + _ => code.ToString(), + }; + + internal static string ParameterTypeName(int code) => + ParameterTypes.FirstOrDefault(kv => kv.Value == code).Key ?? code.ToString(); + + /// Maps a Custom API type code to an OpenAPI (type, format, items-type) triple. + internal static (string Type, string? Format, string? ItemsType) ToOpenApiSchema(int code) => code switch + { + 0 => ("boolean", null, null), + 1 => ("string", "date-time", null), + 2 => ("number", "decimal", null), + 3 => ("object", null, null), + 4 => ("array", null, "object"), + 5 => ("object", null, null), + 6 => ("number", "float", null), + 7 => ("integer", "int32", null), + 8 => ("number", "decimal", null), + 9 => ("integer", "int32", null), + 10 => ("string", null, null), + 11 => ("array", null, "string"), + 12 => ("string", "uuid", null), + _ => ("string", null, null), + }; + + /// + /// Parses a name:type[:optional] parameter definition (e.g. Quantity:integer, + /// Comment:string:optional). Returns null with an error message on bad input. + /// + internal static (string Name, int TypeCode, bool Optional)? ParseParameterSpec(string spec, out string? error) + { + error = null; + var parts = spec.Split(':', StringSplitOptions.TrimEntries); + if (parts.Length is < 2 or > 3 || parts[0].Length == 0) + { + error = $"Invalid parameter spec '{spec}'. Expected format: name:type[:optional]."; + return null; + } + + if (!ParameterTypes.TryGetValue(parts[1], out int typeCode)) + { + error = $"Unknown parameter type '{parts[1]}' in '{spec}'. Valid types: {string.Join(", ", ParameterTypes.Keys)}."; + return null; + } + + bool optional = false; + if (parts.Length == 3) + { + if (!parts[2].Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + error = $"Invalid modifier '{parts[2]}' in '{spec}'. Only 'optional' is allowed."; + return null; + } + optional = true; + } + + return (parts[0], typeCode, optional); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiOpenApiBuilder.cs b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiOpenApiBuilder.cs new file mode 100644 index 00000000..de7d52fd --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/CustomApi/CustomApiOpenApiBuilder.cs @@ -0,0 +1,173 @@ +namespace TALXIS.CLI.Features.Environment.CustomApi; + +/// Custom API definition with its parameters, used as OpenAPI generation input. +internal sealed record CustomApiDefinition( + string UniqueName, + string? DisplayName, + string? Description, + int BindingType, + string? BoundEntity, + bool IsFunction, + IReadOnlyList RequestParameters, + IReadOnlyList ResponseProperties); + +/// A request parameter or response property of a Custom API. +internal sealed record CustomApiParameter( + string UniqueName, + string? DisplayName, + int TypeCode, + bool IsOptional); + +/// +/// Builds an OpenAPI 3.0 document (as a nested dictionary, serialized by the caller) +/// from Custom API definitions. Actions become POST operations, functions become GET. +/// +internal static class CustomApiOpenApiBuilder +{ + internal static Dictionary Build( + IReadOnlyList apis, + string title, + string version, + string? environmentUrl) + { + var paths = new Dictionary(); + foreach (var api in apis.OrderBy(a => a.UniqueName, StringComparer.OrdinalIgnoreCase)) + paths[PathFor(api)] = BuildPathItem(api); + + var document = new Dictionary + { + ["openapi"] = "3.0.3", + ["info"] = new Dictionary + { + ["title"] = title, + ["version"] = version, + ["description"] = "Custom APIs registered in the Dataverse environment. Generated by TALXIS CLI.", + }, + ["paths"] = paths, + }; + + if (!string.IsNullOrWhiteSpace(environmentUrl)) + { + document["servers"] = new List + { + new Dictionary { ["url"] = $"{environmentUrl.TrimEnd('/')}/api/data/v9.2" }, + }; + } + + return document; + } + + internal static string PathFor(CustomApiDefinition api) => api.BindingType switch + { + 1 => $"/{api.BoundEntity}({{id}})/Microsoft.Dynamics.CRM.{api.UniqueName}", + 2 => $"/{api.BoundEntity}/Microsoft.Dynamics.CRM.{api.UniqueName}", + _ => $"/{api.UniqueName}", + }; + + private static Dictionary BuildPathItem(CustomApiDefinition api) + { + var operation = new Dictionary + { + ["operationId"] = api.UniqueName, + ["summary"] = api.DisplayName ?? api.UniqueName, + ["responses"] = BuildResponses(api), + }; + if (!string.IsNullOrWhiteSpace(api.Description)) + operation["description"] = api.Description; + + var parameters = new List(); + if (api.BindingType == 1) + { + parameters.Add(new Dictionary + { + ["name"] = "id", + ["in"] = "path", + ["required"] = true, + ["schema"] = new Dictionary { ["type"] = "string", ["format"] = "uuid" }, + ["description"] = $"Primary key of the bound {api.BoundEntity} record.", + }); + } + + if (api.IsFunction) + { + foreach (var p in api.RequestParameters) + { + parameters.Add(new Dictionary + { + ["name"] = p.UniqueName, + ["in"] = "query", + ["required"] = !p.IsOptional, + ["schema"] = SchemaFor(p.TypeCode), + }); + } + } + else if (api.RequestParameters.Count > 0) + { + operation["requestBody"] = new Dictionary + { + ["required"] = api.RequestParameters.Any(p => !p.IsOptional), + ["content"] = JsonContent(ObjectSchema(api.RequestParameters, includeRequired: true)), + }; + } + + if (parameters.Count > 0) + operation["parameters"] = parameters; + + return new Dictionary { [api.IsFunction ? "get" : "post"] = operation }; + } + + private static Dictionary BuildResponses(CustomApiDefinition api) + { + if (api.ResponseProperties.Count == 0) + { + return new Dictionary + { + ["204"] = new Dictionary { ["description"] = "No content." }, + }; + } + + return new Dictionary + { + ["200"] = new Dictionary + { + ["description"] = "Success.", + ["content"] = JsonContent(ObjectSchema(api.ResponseProperties, includeRequired: false)), + }, + }; + } + + private static Dictionary ObjectSchema(IReadOnlyList parameters, bool includeRequired) + { + var properties = new Dictionary(); + foreach (var p in parameters) + properties[p.UniqueName] = SchemaFor(p.TypeCode); + + var schema = new Dictionary + { + ["type"] = "object", + ["properties"] = properties, + }; + + if (includeRequired) + { + var required = parameters.Where(p => !p.IsOptional).Select(p => (object?)p.UniqueName).ToList(); + if (required.Count > 0) schema["required"] = required; + } + + return schema; + } + + private static Dictionary SchemaFor(int typeCode) + { + var (type, format, itemsType) = CustomApiMaps.ToOpenApiSchema(typeCode); + var schema = new Dictionary { ["type"] = type }; + if (format is not null) schema["format"] = format; + if (itemsType is not null) schema["items"] = new Dictionary { ["type"] = itemsType }; + return schema; + } + + private static Dictionary JsonContent(Dictionary schema) => new() + { + ["application/json"] = new Dictionary { ["schema"] = schema }, + }; +} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 3e26fc7f..87c9d7fb 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand), typeof(CustomApi.CustomApiCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs index b8efeaf5..04d1dbbb 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs @@ -469,10 +469,11 @@ private async Task> ApplyDataBatchAsync( { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var metadataByEntity = await RetrieveRecordMetadataAsync(conn, ops, ct).ConfigureAwait(false); var requests = new OrganizationRequestCollection(); foreach (var op in ops) { - requests.Add(BuildOrganizationRequest(op)); + requests.Add(BuildOrganizationRequest(op, metadataByEntity)); } var response = (ExecuteMultipleResponse)await conn.Client.ExecuteAsync( @@ -548,10 +549,11 @@ private async Task> ApplyDataTransactionAsync( { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var metadataByEntity = await RetrieveRecordMetadataAsync(conn, ops, ct).ConfigureAwait(false); var requests = new OrganizationRequestCollection(); foreach (var op in ops) { - requests.Add(BuildOrganizationRequest(op)); + requests.Add(BuildOrganizationRequest(op, metadataByEntity)); } try @@ -605,6 +607,7 @@ private async Task> ApplyDataBulkAsync( string? profileName, IReadOnlyList ops, bool continueOnError, CancellationToken ct) { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var metadataByEntity = await RetrieveRecordMetadataAsync(conn, ops, ct).ConfigureAwait(false); var results = new List(); // Group operations by (entity, operation type) for bulk messages @@ -633,7 +636,7 @@ private async Task> ApplyDataBulkAsync( { var entities = new EntityCollection { EntityName = entityName }; foreach (var op in groupOps) - entities.Entities.Add(BuildEntity(op)); + entities.Entities.Add(BuildEntity(op, metadataByEntity)); await conn.Client.ExecuteAsync( new CreateMultipleRequest { Targets = entities }, ct).ConfigureAwait(false); @@ -646,7 +649,7 @@ await conn.Client.ExecuteAsync( { var entities = new EntityCollection { EntityName = entityName }; foreach (var op in groupOps) - entities.Entities.Add(BuildEntity(op)); + entities.Entities.Add(BuildEntity(op, metadataByEntity)); await conn.Client.ExecuteAsync( new UpdateMultipleRequest { Targets = entities }, ct).ConfigureAwait(false); @@ -662,7 +665,7 @@ await conn.Client.ExecuteAsync( { try { - await conn.Client.ExecuteAsync(BuildOrganizationRequest(op), ct).ConfigureAwait(false); + await conn.Client.ExecuteAsync(BuildOrganizationRequest(op, metadataByEntity), ct).ConfigureAwait(false); results.Add(new OperationResult(op.Index, true, $"{op.OperationType} {op.TargetType} {op.TargetDescription}")); } @@ -783,12 +786,13 @@ await fileService.UploadFileAsync(profileName, entity, recordId, column, filePat /// Converts a staged data operation into the corresponding Dataverse SDK request. /// File uploads return null — they use the chunked block API and cannot be batched. /// - private static OrganizationRequest? BuildOrganizationRequest(StagedOperation op) + private static OrganizationRequest? BuildOrganizationRequest( + StagedOperation op, IReadOnlyDictionary? metadataByEntity = null) { return (op.TargetType, op.OperationType) switch { - ("record", "CREATE") => new CreateRequest { Target = BuildEntity(op) }, - ("record", "UPDATE") => new UpdateRequest { Target = BuildEntity(op) }, + ("record", "CREATE") => new CreateRequest { Target = BuildEntity(op, metadataByEntity) }, + ("record", "UPDATE") => new UpdateRequest { Target = BuildEntity(op, metadataByEntity) }, ("record", "DELETE") => new DeleteRequest { Target = new EntityReference( @@ -830,9 +834,11 @@ await fileService.UploadFileAsync(profileName, entity, recordId, column, filePat /// /// Builds a Dataverse from a staged record operation's parameters. /// - private static Entity BuildEntity(StagedOperation op) + private static Entity BuildEntity(StagedOperation op, IReadOnlyDictionary? metadataByEntity = null) { var entityName = op.Parameters["entity"]!.ToString()!; + EntityMetadata? metadata = null; + metadataByEntity?.TryGetValue(entityName, out metadata); // The "attributes" (or "data") parameter holds either a JsonElement or a serialized JSON string JsonElement attributesJson; @@ -858,7 +864,37 @@ private static Entity BuildEntity(StagedOperation op) recordId = Guid.Parse(idObj.ToString()!); } - return EntityJsonConverter.JsonToEntity(entityName, attributesJson, recordId); + return EntityJsonConverter.JsonToEntity(entityName, attributesJson, metadata, recordId); + } + + /// + /// Fetches attribute metadata for every entity referenced by record CREATE/UPDATE + /// operations, so JSON values get wrapped into SDK types (OptionSetValue, Money, + /// EntityReference) the same way the direct record service does. + /// + private static async Task> RetrieveRecordMetadataAsync( + DataverseConnection conn, IEnumerable ops, CancellationToken ct) + { + var metadataByEntity = new Dictionary(StringComparer.OrdinalIgnoreCase); + var entityNames = ops + .Where(o => o.TargetType == "record" && o.OperationType is "CREATE" or "UPDATE") + .Select(o => o.Parameters.GetValueOrDefault("entity")?.ToString()) + .Where(n => !string.IsNullOrEmpty(n)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var entityName in entityNames) + { + var request = new RetrieveEntityRequest + { + LogicalName = entityName, + EntityFilters = EntityFilters.Attributes, + RetrieveAsIfPublished = true + }; + var response = (RetrieveEntityResponse)await conn.Client.ExecuteAsync(request, ct).ConfigureAwait(false); + metadataByEntity[entityName!] = response.EntityMetadata; + } + + return metadataByEntity; } /// diff --git a/tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiMapsTests.cs b/tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiMapsTests.cs new file mode 100644 index 00000000..b6a39cd1 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiMapsTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using TALXIS.CLI.Features.Environment.CustomApi; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.CustomApi; + +public sealed class CustomApiMapsTests +{ + [Theory] + [InlineData("Quantity:integer", "Quantity", 7, false)] + [InlineData("Comment:string:optional", "Comment", 10, true)] + [InlineData("Target:entityreference", "Target", 5, false)] + [InlineData("When:DateTime", "When", 1, false)] + public void ParseParameterSpec_ValidSpecs(string spec, string name, int typeCode, bool optional) + { + var result = CustomApiMaps.ParseParameterSpec(spec, out var error); + + Assert.Null(error); + Assert.NotNull(result); + Assert.Equal((name, typeCode, optional), result.Value); + } + + [Theory] + [InlineData("NoType")] + [InlineData(":integer")] + [InlineData("Name:notatype")] + [InlineData("Name:integer:banana")] + [InlineData("Name:integer:optional:extra")] + public void ParseParameterSpec_InvalidSpecs_ReturnError(string spec) + { + var result = CustomApiMaps.ParseParameterSpec(spec, out var error); + + Assert.Null(result); + Assert.NotNull(error); + } + + [Fact] + public void ToSummary_MapsODataRecord() + { + var record = JsonSerializer.SerializeToElement(new Dictionary + { + ["uniquename"] = "udpp_Approve", + ["name"] = "Approve", + ["bindingtype"] = 1, + ["boundentitylogicalname"] = "udpp_warehouseitem", + ["isfunction"] = false, + ["isprivate"] = true, + ["customapiid"] = "7e0edf40-ad7a-f111-ab0e-e4fb1ef8c9e5", + }); + + var summary = CustomApiListCliCommand.ToSummary(record); + + Assert.Equal("udpp_Approve", summary.UniqueName); + Assert.Equal("entity", summary.BindingType); + Assert.Equal("udpp_warehouseitem", summary.BoundEntity); + Assert.False(summary.IsFunction); + Assert.True(summary.IsPrivate); + Assert.Equal(Guid.Parse("7e0edf40-ad7a-f111-ab0e-e4fb1ef8c9e5"), summary.Id); + } + + [Fact] + public void BuildDefinitions_JoinsParametersByApiId() + { + var apiId = "11111111-1111-1111-1111-111111111111"; + var otherId = "22222222-2222-2222-2222-222222222222"; + var apis = new[] { Element(new() { ["customapiid"] = apiId, ["uniquename"] = "udpp_A", ["bindingtype"] = 0, ["isfunction"] = false }) }; + var reqParams = new[] + { + Element(new() { ["uniquename"] = "Mine", ["type"] = 10, ["isoptional"] = false, ["_customapiid_value"] = apiId }), + Element(new() { ["uniquename"] = "Foreign", ["type"] = 10, ["isoptional"] = false, ["_customapiid_value"] = otherId }), + }; + var respProps = new[] { Element(new() { ["uniquename"] = "Out", ["type"] = 7, ["_customapiid_value"] = apiId }) }; + + var definitions = CustomApiGenerateOpenApiCliCommand.BuildDefinitions(apis, reqParams, respProps); + + var definition = Assert.Single(definitions); + Assert.Equal("Mine", Assert.Single(definition.RequestParameters).UniqueName); + Assert.Equal("Out", Assert.Single(definition.ResponseProperties).UniqueName); + } + + private static JsonElement Element(Dictionary values) => + JsonSerializer.SerializeToElement(values); +} diff --git a/tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiOpenApiBuilderTests.cs b/tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiOpenApiBuilderTests.cs new file mode 100644 index 00000000..822b32b4 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/CustomApi/CustomApiOpenApiBuilderTests.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using TALXIS.CLI.Features.Environment.CustomApi; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.CustomApi; + +public sealed class CustomApiOpenApiBuilderTests +{ + [Fact] + public void Build_GlobalAction_ProducesPostWithRequestBodyAndResponse() + { + var api = new CustomApiDefinition( + "udpp_CalculateTotal", "Calculate Total", "Sums line items.", 0, null, false, + [new CustomApiParameter("Quantity", "Quantity", 7, false), new CustomApiParameter("Comment", "Comment", 10, true)], + [new CustomApiParameter("Total", "Total", 8, false)]); + + var doc = CustomApiOpenApiBuilder.Build([api], "Test", "1.0.0", "https://org.crm4.dynamics.com/"); + var json = JsonSerializer.SerializeToElement(doc); + + var operation = json.GetProperty("paths").GetProperty("/udpp_CalculateTotal").GetProperty("post"); + Assert.Equal("udpp_CalculateTotal", operation.GetProperty("operationId").GetString()); + + var bodySchema = operation.GetProperty("requestBody").GetProperty("content") + .GetProperty("application/json").GetProperty("schema"); + Assert.Equal("integer", bodySchema.GetProperty("properties").GetProperty("Quantity").GetProperty("type").GetString()); + Assert.Equal(["Quantity"], bodySchema.GetProperty("required").EnumerateArray().Select(e => e.GetString())); + + var responseSchema = operation.GetProperty("responses").GetProperty("200").GetProperty("content") + .GetProperty("application/json").GetProperty("schema"); + Assert.Equal("number", responseSchema.GetProperty("properties").GetProperty("Total").GetProperty("type").GetString()); + + Assert.Equal("https://org.crm4.dynamics.com/api/data/v9.2", + json.GetProperty("servers")[0].GetProperty("url").GetString()); + } + + [Fact] + public void Build_GlobalFunction_ProducesGetWithQueryParameters() + { + var api = new CustomApiDefinition( + "udpp_GetRate", "Get Rate", null, 0, null, true, + [new CustomApiParameter("Currency", "Currency", 10, false)], + []); + + var doc = CustomApiOpenApiBuilder.Build([api], "Test", "1.0.0", null); + var json = JsonSerializer.SerializeToElement(doc); + + var operation = json.GetProperty("paths").GetProperty("/udpp_GetRate").GetProperty("get"); + var parameter = operation.GetProperty("parameters")[0]; + Assert.Equal("Currency", parameter.GetProperty("name").GetString()); + Assert.Equal("query", parameter.GetProperty("in").GetString()); + Assert.True(parameter.GetProperty("required").GetBoolean()); + + Assert.True(operation.GetProperty("responses").TryGetProperty("204", out _)); + Assert.False(json.TryGetProperty("servers", out _)); + } + + [Fact] + public void Build_EntityBoundAction_IncludesIdPathParameter() + { + var api = new CustomApiDefinition( + "udpp_Approve", "Approve", null, 1, "udpp_warehouseitem", false, [], []); + + var doc = CustomApiOpenApiBuilder.Build([api], "Test", "1.0.0", null); + var json = JsonSerializer.SerializeToElement(doc); + + var path = "/udpp_warehouseitem({id})/Microsoft.Dynamics.CRM.udpp_Approve"; + var operation = json.GetProperty("paths").GetProperty(path).GetProperty("post"); + var parameter = operation.GetProperty("parameters")[0]; + Assert.Equal("id", parameter.GetProperty("name").GetString()); + Assert.Equal("path", parameter.GetProperty("in").GetString()); + Assert.Equal("uuid", parameter.GetProperty("schema").GetProperty("format").GetString()); + } + + [Theory] + [InlineData(0, "boolean", null)] + [InlineData(1, "string", "date-time")] + [InlineData(8, "number", "decimal")] + [InlineData(11, "array", null)] + [InlineData(12, "string", "uuid")] + public void ToOpenApiSchema_MapsTypeCodes(int code, string expectedType, string? expectedFormat) + { + var (type, format, _) = CustomApiMaps.ToOpenApiSchema(code); + Assert.Equal(expectedType, type); + Assert.Equal(expectedFormat, format); + } +}