diff --git a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectorCatalogService.cs b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectorCatalogService.cs new file mode 100644 index 00000000..edd19ad0 --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectorCatalogService.cs @@ -0,0 +1,83 @@ +namespace TALXIS.CLI.Core.Platforms.PowerPlatform; + +/// +/// A connector available in a Power Platform environment (e.g. shared_teams). +/// +public sealed record ConnectorSummary( + string Name, + string DisplayName, + string? Tier, + bool IsCustomApi, + string? Description); + +/// +/// One operation (action or trigger) exposed by a connector, distilled from +/// its OpenAPI definition. +/// +public sealed record ConnectorOperationSummary( + string OperationId, + string Kind, + string? Summary, + string? Visibility, + string? Description); + +/// +/// A single operation parameter. Body schema leaves are flattened into +/// slash-joined names (e.g. item/subject) — exactly the key format a +/// cloud flow definition uses in inputs.parameters. +/// +public sealed record ConnectorOperationParameter( + string Name, + string In, + string? Type, + bool Required, + string? Summary, + string? Description, + IReadOnlyList? EnumValues, + bool IsDynamic); + +/// +/// Full parameter-level detail of one connector operation, ready to be copied +/// into a cloud flow action (host.apiId, host.operationId, +/// inputs.parameters keys). +/// +public sealed record ConnectorOperationDetail( + string ConnectorName, + string ApiId, + string OperationId, + string Kind, + string? Summary, + string? Description, + string HttpMethod, + string Path, + IReadOnlyList Parameters); + +/// +/// Read-only discovery over the connectors available in the target +/// environment and their operations, backed by the Power Apps connector API. +/// Exists so agents authoring cloud flow definitions can look up exact +/// operation ids and parameter names instead of guessing them. +/// +public interface IConnectorCatalogService +{ + /// Lists connectors available in the profile's environment. + Task> ListConnectorsAsync( + string? profileName, + CancellationToken ct); + + /// Lists all operations (actions and triggers) of one connector. + Task> ListOperationsAsync( + string? profileName, + string connectorName, + CancellationToken ct); + + /// + /// Returns parameter-level detail for one operation, or null when the + /// operation does not exist on the connector. + /// + Task GetOperationAsync( + string? profileName, + string connectorName, + string operationId, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md b/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md index 38eda305..ab877b47 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md +++ b/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md @@ -30,12 +30,13 @@ Scaffold components in dependency order: 7. **Project Reference** — `dotnet add reference` from solution project to the separate project. The Build SDK auto-detects the project type and handles registration (assembly data.xml, web resource data.xml, etc.) during `dotnet build` 8. **Ribbon Buttons** (`pp-ribbon-button`) — after entity and script library exist. References the web resource via `LibraryLogicalName=prefix_name` 9. **Form Event Handlers** (`pp-form-event-handler`) — after entity, form, and script library exist. References via `libraryName=prefix_name.js` and `functionName=prefix_name.ClassName.methodName` +10. **Cloud Flows** (`pp-flow`) - after any entity the flow triggers on exists. Dataverse-trigger flows reference an existing connection reference via `ConnectionReferenceLogicalName` (see [flow-development](flow-development.md)) ## Key Parameter Conventions -- **`LogicalName`** (in pp-entity, pp-entity-attribute, pp-optionset-global, pp-app-model): The name **without** publisher prefix. The template adds the prefix automatically. Example: `warehouseitem`, not `udpp_warehouseitem`. +- **`LogicalName`** (in pp-entity, pp-entity-attribute, pp-optionset-global, pp-app-model, pp-flow): The name **without** publisher prefix. The template adds the prefix automatically. Example: `warehouseitem`, not `udpp_warehouseitem`. - **`EntitySchemaName`** (in pp-entity-attribute, pp-entity-form, pp-entity-view, pp-form-*): The entity name **with** publisher prefix. Example: `udpp_warehouseitem`. -- **`EntityLogicalName`** (in pp-sitemap-subarea, pp-app-model-component, pp-ribbon-*): The entity name **with** publisher prefix. Example: `udpp_warehouseitem`. +- **`EntityLogicalName`** (in pp-sitemap-subarea, pp-app-model-component, pp-ribbon-*, pp-flow): The entity name **with** publisher prefix. Example: `udpp_warehouseitem`. - **`AppName`** (in pp-sitemap-*, pp-app-model-component): The app module folder name **with** publisher prefix. Example: `udpp_warehouseapp`. - **`ReferencedEntityName`** (in pp-entity-attribute for Lookup types): The target entity **with** publisher prefix. Example: `udpp_warehouseitem`. - **`Behavior`** (in pp-entity): Use `New` when creating the entity definition for the first time. Use `Existing` to add a reference to an entity owned by another solution (e.g., adding forms/views in a UI solution for an entity defined in the DataModel solution). diff --git a/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md b/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md index 84f83e47..8b0f9bb2 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md +++ b/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md @@ -8,7 +8,7 @@ Custom APIs are custom messages (actions) in Dataverse that expose reusable busi - **Custom API** — reusable callable endpoint, typed request/response, synchronous with return value - **Plugin** — reactive logic triggered by data events (Create, Update, Delete) -- **Power Automate** — low-code automation with connectors and approval flows +- **Power Automate** — low-code automation with connectors and approval flows (see [flow-development](flow-development.md)) Choose Custom API when multiple clients or flows need to invoke the same operation. diff --git a/src/TALXIS.CLI.Features.Docs/Skills/flow-development.md b/src/TALXIS.CLI.Features.Docs/Skills/flow-development.md new file mode 100644 index 00000000..1b0dec9c --- /dev/null +++ b/src/TALXIS.CLI.Features.Docs/Skills/flow-development.md @@ -0,0 +1,48 @@ +# Cloud Flow Development + +## Key Concept + +Power Automate cloud flows are solution components stored locally as source: a JSON definition (Logic Apps workflow schema) under `Workflows/` plus a sibling `.data.xml` metadata file (`Category=5`, `Type=1`). Scaffolding creates the flow locally - it reaches the environment through the normal solution deployment, never by clicking it together in the designer first. + +## Flow Scaffolding Chain + +1. **Scaffold the flow** → `workspace_component_create` with `componentType: "pp-flow"` and a `Trigger` choice: + - `manual` - instant flow started by a button (default) + - `recurrence` - scheduled flow; set `RecurrenceFrequency` (Minute/Hour/Day/Week/Month) and `RecurrenceInterval` + - `dataverse` - automated flow on a Dataverse row event; set `EntityLogicalName`, `TriggerEvent`, `Scope`, optional `FilteringAttributes`, and `ConnectionReferenceLogicalName` +2. **Author actions** by editing the generated `Workflows/_-.json` (rules below) +3. **Build locally** to validate and pack: `dotnet build` +4. Follow the [deployment workflow](deployment-workflow.md) + +Call `workspace_component_parameter_list` for required parameters at each step. + +## Dataverse Trigger Parameters + +- **`TriggerEvent`** → `subscriptionRequest/message`: create=1, delete=2, update=3, create-or-update=4, create-or-delete=5, update-or-delete=6, create-or-update-or-delete=7 +- **`Scope`** → `subscriptionRequest/scope`: user=1, business-unit=2, parent-child-business-unit=3, organization=4 (default) +- **`FilteringAttributes`** - comma-separated attribute logical names; the flow only fires when one of them changes (meaningful for events that include update) +- **`EntityLogicalName`** - entity logical name **with** publisher prefix (e.g. `udpp_warehouseitem`) +- **`ConnectionReferenceLogicalName`** - logical name of an **existing** Dataverse connection reference; connection references are declared under `` in `Other/Customizations.xml` + +## Authoring Actions in the Definition JSON + +- The definition must declare both `$connections` and `$authentication` parameters (the template already does) +- Standard connector actions use `"type": "OpenApiConnection"` (never `ApiConnection`); webhook-style operations (e.g. Approvals) use `"type": "OpenApiConnectionWebhook"` +- Every connector action's `host.connectionName` must match a key in `properties.connectionReferences`; each entry needs `"runtimeSource": "embedded"` and a real `connectionReferenceLogicalName` +- `runAfter` must reference existing action names; the first action uses `"runAfter": {}` +- Every `OpenApiConnection`/`OpenApiConnectionWebhook` trigger and action passes `"authentication": "@parameters('$authentication')"` inside `inputs` (real exported flows carry it for all connectors, not just Dataverse) +- Never guess connector `operationId`, `apiId`, or parameter names - discover them live: `environment_connector_list` (available connectors), `environment_connector_operation_list` (operations with kind action/trigger/webhook-trigger), `environment_connector_operation_get` (exact parameter names, types, enums; body leaves are already slash-joined like `emailMessage/To`) +- `webhook-trigger` operations go into `triggers` as `OpenApiConnectionWebhook`; parameters marked dynamic require values resolved at authoring time +- Dataverse `CreateRecord`/`UpdateRecord` bodies are dynamic: keys are `item/{attribute logical name}` from the entity schema, with the `@odata.bind` suffix for lookups (e.g. `item/ownerid@odata.bind`) +- Expressions use `@{...}` interpolation with functions like `triggerOutputs()`, `outputs('')?['body/field']`, `concat()`, `utcNow()` + +## What NOT to Do + +- ❌ Don't create flows directly in the environment - scaffold locally so the flow lives in source control +- ❌ Don't scaffold a `dataverse`-trigger flow before the triggering entity exists in the workspace +- ❌ Don't reference a connection reference that doesn't exist - declare it in `Other/Customizations.xml` first +- ❌ Don't invent connector operation parameters - mirror a real flow or a designer export +- ❌ Don't use premium triggers (e.g. HTTP request) without checking licensing; if a DLP policy blocks a trigger connector, fall back to a `recurrence` trigger that polls instead +- ❌ Don't rename the flow JSON file by hand - `JsonFileName` inside the `.data.xml` must keep matching it exactly + +See also: [component-creation](component-creation.md), [deployment-workflow](deployment-workflow.md) diff --git a/src/TALXIS.CLI.Features.Docs/Skills/index.json b/src/TALXIS.CLI.Features.Docs/Skills/index.json index 67ecc807..82d94732 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/index.json +++ b/src/TALXIS.CLI.Features.Docs/Skills/index.json @@ -14,5 +14,6 @@ {"id": "pcf-controls", "title": "PCF Control Development", "summary": "PCF project structure, ControlManifest, lifecycle methods, dataset vs field controls, and build workflow.", "tags": ["workspace", "local-development", "pcf"]}, {"id": "build-errors", "title": "Build Error Recovery", "summary": "Diagnose and fix TALXISXSD001, TALXISGUID001, and other build validation errors.", "tags": ["troubleshooting", "build", "validation"]}, {"id": "data-querying", "title": "Data Querying", "summary": "Query Dataverse data using SQL, OData, and FetchXML — choosing the right language, OData patterns, aggregation, and pagination.", "tags": ["data-operations", "environment"]}, - {"id": "security-roles", "title": "Security Role Scaffolding", "summary": "Scaffold security roles, add privileges, and assign roles to model-driven apps.", "tags": ["workspace", "local-development", "security"]} + {"id": "security-roles", "title": "Security Role Scaffolding", "summary": "Scaffold security roles, add privileges, and assign roles to model-driven apps.", "tags": ["workspace", "local-development", "security"]}, + {"id": "flow-development", "title": "Cloud Flow Development", "summary": "Power Automate cloud flow structure, trigger selection, connection references, action authoring rules, and scaffolding with pp-flow.", "tags": ["workspace", "local-development", "flows"]} ] diff --git a/src/TALXIS.CLI.Features.Environment/Connector/ConnectorCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorCliCommand.cs new file mode 100644 index 00000000..d329d5ae --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorCliCommand.cs @@ -0,0 +1,21 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Connector; + +[CliCommand( + Name = "connector", + Description = "Discover connectors and their operations in the live environment (for authoring cloud flows).", + Children = new[] + { + typeof(ConnectorListCliCommand), + typeof(Operation.ConnectorOperationCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class ConnectorCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Connector/ConnectorListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorListCliCommand.cs new file mode 100644 index 00000000..2c452755 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorListCliCommand.cs @@ -0,0 +1,67 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Connector; + +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List the connectors available in the profile's environment." +)] +public class ConnectorListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ConnectorListCliCommand)); + + [CliOption(Name = "--filter", Description = "Show only connectors whose name or display name contains this substring.", Required = false)] + public string? Filter { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + IReadOnlyList connectors = await service.ListConnectorsAsync(Profile, CancellationToken.None) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(Filter)) + { + connectors = connectors + .Where(c => Contains(c.Name, Filter) || Contains(c.DisplayName, Filter)) + .ToList(); + } + + OutputFormatter.WriteList(connectors, PrintTable); + return ExitSuccess; + } + + private static bool Contains(string? value, string substring) + => value is not null && value.Contains(substring, StringComparison.OrdinalIgnoreCase); + + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList connectors) + { + if (connectors.Count == 0) + { + OutputWriter.WriteLine("No connectors found."); + return; + } + + int nameWidth = Math.Clamp(connectors.Max(c => c.Name.Length), 20, 60); + int displayWidth = Math.Clamp(connectors.Max(c => c.DisplayName.Length), 12, 40); + string header = $"{"Name".PadRight(nameWidth)} | {"Display Name".PadRight(displayWidth)} | {"Tier".PadRight(10)} | Custom"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var c in connectors) + { + string name = c.Name.Length > nameWidth ? c.Name[..(nameWidth - 1)] + "." : c.Name; + string display = c.DisplayName.Length > displayWidth ? c.DisplayName[..(displayWidth - 1)] + "." : c.DisplayName; + OutputWriter.WriteLine( + $"{name.PadRight(nameWidth)} | {display.PadRight(displayWidth)} | {(c.Tier ?? string.Empty).PadRight(10)} | {(c.IsCustomApi ? "yes" : string.Empty)}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationCliCommand.cs new file mode 100644 index 00000000..7088fd2e --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationCliCommand.cs @@ -0,0 +1,21 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Connector.Operation; + +[CliCommand( + Name = "operation", + Description = "Inspect the operations (actions and triggers) a connector exposes.", + Children = new[] + { + typeof(ConnectorOperationListCliCommand), + typeof(ConnectorOperationGetCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class ConnectorOperationCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationGetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationGetCliCommand.cs new file mode 100644 index 00000000..166d465b --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationGetCliCommand.cs @@ -0,0 +1,84 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Connector.Operation; + +[CliReadOnly] +[CliCommand( + Name = "get", + Description = "Show one connector operation with its exact parameter names, types, and enum values for flow authoring." +)] +public class ConnectorOperationGetCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ConnectorOperationGetCliCommand)); + + [CliOption(Name = "--connector", Description = "Connector name as returned by 'connector list' (e.g. shared_commondataserviceforapps).", Required = true)] + public string Connector { get; set; } = null!; + + [CliOption(Name = "--operation", Description = "Operation id as returned by 'connector operation list' (e.g. CreateRecord).", Required = true)] + public string Operation { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + ConnectorOperationDetail? detail = await service + .GetOperationAsync(Profile, Connector, Operation, CancellationToken.None) + .ConfigureAwait(false); + + if (detail is null) + { + Logger.LogError( + "Operation '{Operation}' was not found on connector '{Connector}'. Use 'txc environment connector operation list --connector {Connector}' to see available operations.", + Operation, Connector, Connector); + return ExitValidationError; + } + + OutputFormatter.WriteData(detail, PrintDetail); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteData — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintDetail(ConnectorOperationDetail d) + { + const int labelWidth = -14; + OutputWriter.WriteLine($"{"Operation:",labelWidth}{d.OperationId}"); + OutputWriter.WriteLine($"{"Connector:",labelWidth}{d.ConnectorName}"); + OutputWriter.WriteLine($"{"Api Id:",labelWidth}{d.ApiId}"); + OutputWriter.WriteLine($"{"Kind:",labelWidth}{d.Kind}"); + OutputWriter.WriteLine($"{"Endpoint:",labelWidth}{d.HttpMethod} {d.Path}"); + if (!string.IsNullOrWhiteSpace(d.Summary)) + OutputWriter.WriteLine($"{"Summary:",labelWidth}{d.Summary}"); + + OutputWriter.WriteLine(string.Empty); + + if (d.Parameters.Count == 0) + { + OutputWriter.WriteLine("No parameters."); + return; + } + + int nameWidth = Math.Clamp(d.Parameters.Max(p => p.Name.Length), 20, 60); + int typeWidth = 18; + string header = $"{"Parameter".PadRight(nameWidth)} | {"In".PadRight(6)} | {"Type".PadRight(typeWidth)} | {"Req".PadRight(3)} | {"Dyn".PadRight(3)} | Summary"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var p in d.Parameters) + { + string name = p.Name.Length > nameWidth ? p.Name[..(nameWidth - 1)] + "." : p.Name; + string type = p.Type ?? string.Empty; + type = type.Length > typeWidth ? type[..(typeWidth - 1)] + "." : type; + string summary = p.Summary ?? string.Empty; + if (p.EnumValues is { Count: > 0 }) + summary = $"{summary} [{string.Join(", ", p.EnumValues)}]".Trim(); + OutputWriter.WriteLine( + $"{name.PadRight(nameWidth)} | {p.In.PadRight(6)} | {type.PadRight(typeWidth)} | {(p.Required ? "yes" : "").PadRight(3)} | {(p.IsDynamic ? "yes" : "").PadRight(3)} | {summary}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationListCliCommand.cs new file mode 100644 index 00000000..2c943912 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationListCliCommand.cs @@ -0,0 +1,75 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Connector.Operation; + +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List the operations (actions and triggers) of a connector in the profile's environment." +)] +public class ConnectorOperationListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ConnectorOperationListCliCommand)); + + [CliOption(Name = "--connector", Description = "Connector name as returned by 'connector list' (e.g. shared_commondataserviceforapps).", Required = true)] + public string Connector { get; set; } = null!; + + [CliOption(Name = "--kind", Description = "Show only operations of this kind (action, trigger, webhook-trigger).", Required = false)] + public string? Kind { get; set; } + + private static readonly string[] KnownKinds = ["action", "trigger", "webhook-trigger"]; + + protected override async Task ExecuteAsync() + { + if (!string.IsNullOrWhiteSpace(Kind) && !KnownKinds.Contains(Kind, StringComparer.OrdinalIgnoreCase)) + { + Logger.LogError( + "Invalid --kind '{Kind}'. Expected one of: action, trigger, webhook-trigger.", Kind); + return ExitValidationError; + } + + var service = TxcServices.Get(); + IReadOnlyList operations = await service + .ListOperationsAsync(Profile, Connector, CancellationToken.None) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(Kind)) + { + operations = operations + .Where(o => string.Equals(o.Kind, Kind, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + OutputFormatter.WriteList(operations, PrintTable); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList operations) + { + if (operations.Count == 0) + { + OutputWriter.WriteLine("No operations found."); + return; + } + + int idWidth = Math.Clamp(operations.Max(o => o.OperationId.Length), 20, 50); + string header = $"{"Operation Id".PadRight(idWidth)} | {"Kind".PadRight(15)} | Summary"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var o in operations) + { + string id = o.OperationId.Length > idWidth ? o.OperationId[..(idWidth - 1)] + "." : o.OperationId; + OutputWriter.WriteLine( + $"{id.PadRight(idWidth)} | {o.Kind.PadRight(15)} | {o.Summary}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 3e26fc7f..65124c19 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(Connector.ConnectorCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md b/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md index baa587ef..46faf0c3 100644 --- a/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md +++ b/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md @@ -30,6 +30,11 @@ 1. BPF entity → 2. BPFStage (repeat) → 3. BPFStageStep (repeat per stage) - Entity must have IsBPFEntity=1; XAML workflow needs Category=4, TriggerOnCreate=1 +## Flow Chain +1. Flow (pp-flow, pick Trigger: manual/recurrence/dataverse) → 2. Edit definition JSON to add actions +- Dataverse trigger: entity MUST exist; ConnectionReferenceLogicalName MUST point to an existing connection reference in Other/Customizations.xml +- See flow-development skill for trigger parameters and action JSON rules + ## Local vs Live - ALL chains above: use workspace tools (local, instant, reversible) - Environment tools ONLY for: inspection, layer troubleshooting, import, publish diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs index fa78126c..0ae0a8d3 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs @@ -58,6 +58,8 @@ public static IServiceCollection AddTxcDataverseProvider(this IServiceCollection EnvironmentSettingsService>(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/ConnectorCatalogService.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/ConnectorCatalogService.cs new file mode 100644 index 00000000..99e32b1c --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/ConnectorCatalogService.cs @@ -0,0 +1,273 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Platforms.PowerPlatform; +using TALXIS.CLI.Platform.PowerPlatform.Control.Bap; +using TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control; + +/// +/// Discovers connectors and their operations in the profile's environment via +/// the Power Apps connector API. Reuses the BAP transport because both APIs +/// validate the same service.powerapps.com audience, so one cached +/// token serves both. +/// +public sealed class ConnectorCatalogService : IConnectorCatalogService +{ + private readonly IConfigurationResolver _resolver; + private readonly IPowerPlatformEnvironmentCatalog _catalog; + private readonly BapAdminApiClient _client; + private readonly IHttpClientFactoryWrapper _httpFactory; + private readonly ILogger _logger; + + public ConnectorCatalogService( + IConfigurationResolver resolver, + IPowerPlatformEnvironmentCatalog catalog, + IAccessTokenService tokens, + IHttpClientFactoryWrapper? httpFactory = null, + ILoggerFactory? loggerFactory = null) + { + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _client = new BapAdminApiClient(tokens, httpFactory); + _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + _logger = loggerFactory?.CreateLogger() + ?? NullLogger.Instance; + } + + public async Task> ListConnectorsAsync( + string? profileName, CancellationToken ct) + { + var (ctx, baseUri, environmentId) = await PrepareAsync(profileName, ct).ConfigureAwait(false); + var token = await _client.AcquireTokenAsync(ctx.Connection, ctx.Credential, ct).ConfigureAwait(false); + + var connectors = new List(); + Uri? nextPage = new(baseUri, + $"providers/Microsoft.PowerApps/apis?api-version={PowerAppsEndpointProvider.ApiVersion}&$filter={BuildEnvironmentFilter(environmentId)}"); + + while (nextPage is not null) + { + var response = await _client.SendAsync(HttpMethod.Get, nextPage, token, jsonBody: null, ct).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw new InvalidOperationException( + $"Power Apps connector lookup failed ({(int)response.StatusCode} {response.StatusCode}) against '{nextPage}': {BapAdminApiClient.Truncate(response.Body, 500)}"); + } + + using var document = JsonDocument.Parse(response.Body); + var root = document.RootElement; + if (!root.TryGetProperty("value", out var items) || items.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException("Power Apps connector lookup returned a payload without a 'value' array."); + + foreach (var item in items.EnumerateArray()) + { + if (TryParseConnector(item, out var connector)) + connectors.Add(connector); + } + + nextPage = TryReadNextLink(root, baseUri); + } + + return connectors + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task> ListOperationsAsync( + string? profileName, string connectorName, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectorName); + + var swagger = await GetSwaggerAsync(profileName, connectorName, ct).ConfigureAwait(false); + return ConnectorSwaggerParser.ListOperations(swagger); + } + + public async Task GetOperationAsync( + string? profileName, string connectorName, string operationId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectorName); + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + + var swagger = await GetSwaggerAsync(profileName, connectorName, ct).ConfigureAwait(false); + return ConnectorSwaggerParser.GetOperation(swagger, connectorName, BuildApiId(connectorName), operationId); + } + + private async Task GetSwaggerAsync(string? profileName, string connectorName, CancellationToken ct) + { + var (ctx, baseUri, environmentId) = await PrepareAsync(profileName, ct).ConfigureAwait(false); + var token = await _client.AcquireTokenAsync(ctx.Connection, ctx.Credential, ct).ConfigureAwait(false); + + var requestUri = new Uri(baseUri, + $"providers/Microsoft.PowerApps/apis/{Uri.EscapeDataString(connectorName)}?api-version={PowerAppsEndpointProvider.ApiVersion}&$expand=properties.swagger&$filter={BuildEnvironmentFilter(environmentId)}"); + + var response = await _client.SendAsync(HttpMethod.Get, requestUri, token, jsonBody: null, ct).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + throw new InvalidOperationException( + $"Connector '{connectorName}' was not found in the environment. Verify the connector name with 'txc environment connector list'."); + } + + if (!response.IsSuccess) + { + throw new InvalidOperationException( + $"Power Apps connector lookup for '{connectorName}' failed ({(int)response.StatusCode} {response.StatusCode}) against '{requestUri}': {BapAdminApiClient.Truncate(response.Body, 500)}"); + } + + using (var document = JsonDocument.Parse(response.Body)) + { + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("properties", out var properties) + && properties.ValueKind == JsonValueKind.Object) + { + if (properties.TryGetProperty("swagger", out var swagger) && swagger.ValueKind == JsonValueKind.Object) + return swagger.Clone(); + + // Some connectors omit the inline swagger even with $expand; their + // definition is only reachable through the apiDefinitions blob URL. + if (properties.TryGetProperty("apiDefinitions", out var definitions) + && definitions.ValueKind == JsonValueKind.Object + && definitions.TryGetProperty("originalSwaggerUrl", out var swaggerUrl) + && swaggerUrl.ValueKind == JsonValueKind.String + && Uri.TryCreate(swaggerUrl.GetString(), UriKind.Absolute, out var blobUri)) + { + _logger.LogInformation( + "Connector {Connector} has no inline swagger. Downloading definition from apiDefinitions.", + connectorName); + return await DownloadSwaggerAsync(blobUri, ct).ConfigureAwait(false); + } + } + } + + throw new InvalidOperationException( + $"Connector '{connectorName}' returned no OpenAPI definition. Verify the connector name with 'txc environment connector list'."); + } + + private async Task DownloadSwaggerAsync(Uri blobUri, CancellationToken ct) + { + // The apiDefinitions URL is a pre-signed blob link; an Authorization + // header would make Azure Storage reject the request, so plain GET. + using var http = _httpFactory.Create(); + using var response = await http.GetAsync(blobUri, ct).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Downloading the connector OpenAPI definition failed ({(int)response.StatusCode} {response.StatusCode}) against '{blobUri}': {BapAdminApiClient.Truncate(body, 500)}"); + } + + using var document = JsonDocument.Parse(body); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"The connector OpenAPI definition downloaded from '{blobUri}' is not a JSON object."); + } + + return document.RootElement.Clone(); + } + + private async Task<(ResolvedProfileContext Context, Uri BaseUri, Guid EnvironmentId)> PrepareAsync( + string? profileName, CancellationToken ct) + { + var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + var baseUri = PowerAppsEndpointProvider.GetApiBaseUri(ctx.Connection.Cloud ?? CloudInstance.Public); + var environmentId = await ResolveEnvironmentIdAsync(ctx, ct).ConfigureAwait(false); + return (ctx, baseUri, environmentId); + } + + private async Task ResolveEnvironmentIdAsync(ResolvedProfileContext ctx, CancellationToken ct) + { + if (ctx.Connection.EnvironmentId.HasValue) + return ctx.Connection.EnvironmentId.Value; + + if (string.IsNullOrWhiteSpace(ctx.Connection.EnvironmentUrl) + || !Uri.TryCreate(ctx.Connection.EnvironmentUrl, UriKind.Absolute, out var envUri)) + { + throw new InvalidOperationException( + $"Connection '{ctx.Connection.Id}' has no EnvironmentUrl or EnvironmentId."); + } + + _logger.LogInformation( + "EnvironmentId not stored on connection '{ConnectionId}'. Resolving via Power Platform catalog...", + ctx.Connection.Id); + + var env = await _catalog + .TryGetByEnvironmentUrlAsync(ctx.Connection, ctx.Credential, envUri, ct) + .ConfigureAwait(false); + + if (env is null) + { + throw new InvalidOperationException( + $"Could not resolve Power Platform environment for URL '{ctx.Connection.EnvironmentUrl}'."); + } + + return env.EnvironmentId; + } + + private static string BuildEnvironmentFilter(Guid environmentId) + => Uri.EscapeDataString($"environment eq '{environmentId:D}'"); + + private static string BuildApiId(string connectorName) + => $"/providers/Microsoft.PowerApps/apis/{connectorName}"; + + private static bool TryParseConnector(JsonElement item, out ConnectorSummary connector) + { + connector = null!; + + if (!item.TryGetProperty("name", out var nameElement) + || nameElement.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(nameElement.GetString())) + { + return false; + } + + string? displayName = null; + string? tier = null; + string? description = null; + var isCustomApi = false; + + if (item.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object) + { + displayName = TryReadOptionalString(properties, "displayName"); + tier = TryReadOptionalString(properties, "tier"); + description = TryReadOptionalString(properties, "description"); + isCustomApi = properties.TryGetProperty("isCustomApi", out var custom) + && (custom.ValueKind == JsonValueKind.True + || (custom.ValueKind == JsonValueKind.String + && bool.TryParse(custom.GetString(), out var parsed) && parsed)); + } + + var name = nameElement.GetString()!.Trim(); + connector = new ConnectorSummary( + Name: name, + DisplayName: displayName ?? name, + Tier: tier, + IsCustomApi: isCustomApi, + Description: description); + return true; + } + + private static Uri? TryReadNextLink(JsonElement root, Uri baseUri) + { + if (!root.TryGetProperty("nextLink", out var nextLinkElement) + || nextLinkElement.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(nextLinkElement.GetString())) + { + return null; + } + + var nextLink = nextLinkElement.GetString()!; + if (Uri.TryCreate(nextLink, UriKind.Absolute, out var absolute)) + return absolute; + + return Uri.TryCreate(baseUri, nextLink, out var relative) ? relative : null; + } + + private static string? TryReadOptionalString(JsonElement element, string property) + => element.TryGetProperty(property, out var propertyElement) && propertyElement.ValueKind == JsonValueKind.String + ? propertyElement.GetString()?.Trim() + : null; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/ConnectorSwaggerParser.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/ConnectorSwaggerParser.cs new file mode 100644 index 00000000..8d2bae7d --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/ConnectorSwaggerParser.cs @@ -0,0 +1,375 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Platforms.PowerPlatform; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps; + +/// +/// Distills a connector's OpenAPI 2.0 document into the small operation and +/// parameter model that flow-authoring agents need. Body schemas are flattened +/// into slash-joined leaf names (item/subject) because that is the key +/// format cloud flow definitions use in inputs.parameters. +/// +internal static class ConnectorSwaggerParser +{ + private static readonly string[] HttpVerbs = ["get", "put", "post", "delete", "patch", "head", "options"]; + + // Body schemas can nest arbitrarily; flow parameter keys rarely go deeper. + private const int MaxFlattenDepth = 4; + + public static IReadOnlyList ListOperations(JsonElement swagger) + { + var operations = new List(); + + foreach (var (_, pathItem, _, operation) in EnumerateOperations(swagger)) + { + if (!TryReadString(operation, "operationId", out var operationId)) + continue; + + operations.Add(new ConnectorOperationSummary( + OperationId: operationId, + Kind: ResolveKind(pathItem, operation), + Summary: TryReadOptionalString(operation, "summary"), + Visibility: TryReadOptionalString(operation, "x-ms-visibility"), + Description: TryReadOptionalString(operation, "description"))); + } + + return operations; + } + + public static ConnectorOperationDetail? GetOperation( + JsonElement swagger, + string connectorName, + string apiId, + string operationId) + { + foreach (var (path, pathItem, verb, operation) in EnumerateOperations(swagger)) + { + if (!TryReadString(operation, "operationId", out var candidate) + || !string.Equals(candidate, operationId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return new ConnectorOperationDetail( + ConnectorName: connectorName, + ApiId: apiId, + OperationId: candidate, + Kind: ResolveKind(pathItem, operation), + Summary: TryReadOptionalString(operation, "summary"), + Description: TryReadOptionalString(operation, "description"), + HttpMethod: verb.ToUpperInvariant(), + Path: path, + Parameters: CollectParameters(swagger, pathItem, operation)); + } + + return null; + } + + private static IEnumerable<(string Path, JsonElement PathItem, string Verb, JsonElement Operation)> EnumerateOperations( + JsonElement swagger) + { + if (!swagger.TryGetProperty("paths", out var paths) || paths.ValueKind != JsonValueKind.Object) + yield break; + + foreach (var pathProperty in paths.EnumerateObject()) + { + if (pathProperty.Value.ValueKind != JsonValueKind.Object) + continue; + + foreach (var verb in HttpVerbs) + { + if (pathProperty.Value.TryGetProperty(verb, out var operation) + && operation.ValueKind == JsonValueKind.Object) + { + yield return (pathProperty.Name, pathProperty.Value, verb, operation); + } + } + } + } + + private static string ResolveKind(JsonElement pathItem, JsonElement operation) + { + if (!operation.TryGetProperty("x-ms-trigger", out _)) + return "action"; + + // Webhook triggers register a callback URL instead of polling. + return pathItem.TryGetProperty("x-ms-notification-content", out _) + || operation.TryGetProperty("x-ms-notification-content", out _) + ? "webhook-trigger" + : "trigger"; + } + + private static IReadOnlyList CollectParameters( + JsonElement swagger, + JsonElement pathItem, + JsonElement operation) + { + var parameters = new List(); + + foreach (var parameter in EnumerateDeclaredParameters(swagger, pathItem, operation)) + { + if (IsInternal(parameter)) + continue; + + var location = TryReadOptionalString(parameter, "in") ?? "query"; + if (!string.Equals(location, "body", StringComparison.OrdinalIgnoreCase)) + { + parameters.Add(new ConnectorOperationParameter( + Name: TryReadOptionalString(parameter, "name") ?? string.Empty, + In: location, + Type: ReadTypeLabel(parameter), + Required: ReadRequiredFlag(parameter), + Summary: TryReadOptionalString(parameter, "x-ms-summary"), + Description: TryReadOptionalString(parameter, "description"), + EnumValues: ReadEnumValues(parameter), + IsDynamic: HasDynamicExtension(parameter))); + continue; + } + + FlattenBodyParameter(swagger, parameter, parameters); + } + + return parameters; + } + + private static IEnumerable EnumerateDeclaredParameters( + JsonElement swagger, + JsonElement pathItem, + JsonElement operation) + { + // Swagger 2.0: an operation-level parameter overrides a pathItem-level + // one with the same (name, in) pair. + var merged = new List(); + var indexByKey = new Dictionary<(string Name, string In), int>(); + + foreach (var owner in new[] { pathItem, operation }) + { + if (!owner.TryGetProperty("parameters", out var declared) || declared.ValueKind != JsonValueKind.Array) + continue; + + foreach (var parameter in declared.EnumerateArray()) + { + var resolved = ResolveRef(swagger, parameter); + if (resolved.ValueKind != JsonValueKind.Object) + continue; + + var key = ( + TryReadOptionalString(resolved, "name") ?? string.Empty, + TryReadOptionalString(resolved, "in") ?? "query"); + if (indexByKey.TryGetValue(key, out var existing)) + { + merged[existing] = resolved; + } + else + { + indexByKey[key] = merged.Count; + merged.Add(resolved); + } + } + } + + return merged; + } + + private static void FlattenBodyParameter( + JsonElement swagger, + JsonElement parameter, + List parameters) + { + var name = TryReadOptionalString(parameter, "name") ?? "body"; + var required = ReadRequiredFlag(parameter); + + if (!parameter.TryGetProperty("schema", out var schema)) + return; + + var resolved = ResolveRef(swagger, schema); + + // Dynamic schemas depend on another parameter's value (e.g. the picked + // entity); surface a single marker parameter instead of empty output. + if (HasDynamicSchema(resolved)) + { + parameters.Add(new ConnectorOperationParameter( + name, "body", "dynamic", required, + TryReadOptionalString(parameter, "x-ms-summary"), + TryReadOptionalString(parameter, "description"), + EnumValues: null, + IsDynamic: true)); + return; + } + + // Real flow definitions key body values as {bodyParamName}/{leaf} + // (e.g. item/subject, emailMessage/To), so the flatten prefix starts + // with the body parameter name. + var countBeforeFlatten = parameters.Count; + FlattenSchema(swagger, resolved, prefix: name, required, parameters, depth: 0); + + if (parameters.Count == countBeforeFlatten) + { + parameters.Add(new ConnectorOperationParameter( + name, "body", ReadTypeLabel(resolved), required, + TryReadOptionalString(parameter, "x-ms-summary"), + TryReadOptionalString(parameter, "description"), + ReadEnumValues(resolved), + HasDynamicExtension(resolved))); + } + } + + private static void FlattenSchema( + JsonElement swagger, + JsonElement schema, + string? prefix, + bool ancestorsRequired, + List parameters, + int depth) + { + if (depth > MaxFlattenDepth) + return; + + if (schema.TryGetProperty("allOf", out var allOf) && allOf.ValueKind == JsonValueKind.Array) + { + foreach (var branch in allOf.EnumerateArray()) + { + var resolvedBranch = ResolveRef(swagger, branch); + if (resolvedBranch.ValueKind == JsonValueKind.Object) + FlattenSchema(swagger, resolvedBranch, prefix, ancestorsRequired, parameters, depth + 1); + } + } + + if (!schema.TryGetProperty("properties", out var properties) || properties.ValueKind != JsonValueKind.Object) + return; + + var requiredNames = ReadRequiredNames(schema); + + foreach (var property in properties.EnumerateObject()) + { + var propertySchema = ResolveRef(swagger, property.Value); + if (propertySchema.ValueKind != JsonValueKind.Object || IsInternal(propertySchema) || IsReadOnly(propertySchema)) + continue; + + var name = prefix is null ? property.Name : $"{prefix}/{property.Name}"; + var required = ancestorsRequired && requiredNames.Contains(property.Name); + var isDynamicSchema = HasDynamicSchema(propertySchema); + + if (!isDynamicSchema && IsFlattenable(propertySchema) && depth < MaxFlattenDepth) + { + FlattenSchema(swagger, propertySchema, name, required, parameters, depth + 1); + continue; + } + + var truncated = !isDynamicSchema && IsFlattenable(propertySchema); + parameters.Add(new ConnectorOperationParameter( + Name: name, + In: "body", + Type: isDynamicSchema ? "dynamic" : truncated ? "object (truncated)" : ReadTypeLabel(propertySchema), + Required: required, + Summary: TryReadOptionalString(propertySchema, "x-ms-summary"), + Description: TryReadOptionalString(propertySchema, "description"), + EnumValues: ReadEnumValues(propertySchema), + IsDynamic: isDynamicSchema || HasDynamicExtension(propertySchema))); + } + } + + private static JsonElement ResolveRef(JsonElement swagger, JsonElement element) + { + var visited = new HashSet(StringComparer.Ordinal); + + while (element.ValueKind == JsonValueKind.Object + && element.TryGetProperty("$ref", out var refElement) + && refElement.ValueKind == JsonValueKind.String) + { + var reference = refElement.GetString(); + if (string.IsNullOrWhiteSpace(reference) || !reference.StartsWith("#/", StringComparison.Ordinal) || !visited.Add(reference)) + return element; + + var current = swagger; + foreach (var segment in reference[2..].Split('/')) + { + if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out current)) + return element; + } + + element = current; + } + + return element; + } + + private static bool IsFlattenable(JsonElement schema) + => schema.ValueKind == JsonValueKind.Object + && ((schema.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object) + || (schema.TryGetProperty("allOf", out var allOf) && allOf.ValueKind == JsonValueKind.Array)); + + private static bool IsInternal(JsonElement element) + => string.Equals(TryReadOptionalString(element, "x-ms-visibility"), "internal", StringComparison.OrdinalIgnoreCase); + + private static bool IsReadOnly(JsonElement element) + => element.TryGetProperty("readOnly", out var readOnly) && readOnly.ValueKind == JsonValueKind.True; + + private static bool ReadRequiredFlag(JsonElement parameter) + => parameter.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.True; + + private static HashSet ReadRequiredNames(JsonElement schema) + { + var names = new HashSet(StringComparer.Ordinal); + if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array) + { + foreach (var name in required.EnumerateArray()) + { + if (name.ValueKind == JsonValueKind.String) + names.Add(name.GetString()!); + } + } + + return names; + } + + private static string? ReadTypeLabel(JsonElement element) + { + var type = TryReadOptionalString(element, "type"); + var format = TryReadOptionalString(element, "format"); + return type is null ? null : format is null ? type : $"{type} ({format})"; + } + + private static IReadOnlyList? ReadEnumValues(JsonElement element) + { + if (!element.TryGetProperty("enum", out var enumElement) || enumElement.ValueKind != JsonValueKind.Array) + return null; + + var values = enumElement.EnumerateArray() + .Select(v => v.ValueKind == JsonValueKind.String ? v.GetString()! : v.GetRawText()) + .ToList(); + return values.Count > 0 ? values : null; + } + + private static bool HasDynamicExtension(JsonElement element) + => element.ValueKind == JsonValueKind.Object + && (element.TryGetProperty("x-ms-dynamic-values", out _) + || element.TryGetProperty("x-ms-dynamic-list", out _) + || element.TryGetProperty("x-ms-dynamic-tree", out _)); + + private static bool HasDynamicSchema(JsonElement element) + => element.ValueKind == JsonValueKind.Object + && (element.TryGetProperty("x-ms-dynamic-schema", out _) + || element.TryGetProperty("x-ms-dynamic-properties", out _)); + + private static bool TryReadString(JsonElement element, string property, out string value) + { + value = string.Empty; + if (!element.TryGetProperty(property, out var propertyElement) || propertyElement.ValueKind != JsonValueKind.String) + return false; + + var raw = propertyElement.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + value = raw.Trim(); + return true; + } + + private static string? TryReadOptionalString(JsonElement element, string property) + => element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(property, out var propertyElement) + && propertyElement.ValueKind == JsonValueKind.String + ? propertyElement.GetString()?.Trim() + : null; +} diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/PowerAppsEndpointProvider.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/PowerAppsEndpointProvider.cs new file mode 100644 index 00000000..7ba2239e --- /dev/null +++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/PowerAppsEndpointProvider.cs @@ -0,0 +1,29 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control.Bap; + +namespace TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps; + +/// +/// Endpoint constants for the Power Apps connector API (api.powerapps.com). +/// Kept intentionally explicit so an unmapped cloud fails loudly rather than +/// silently targeting the wrong host — the sovereign-cloud hosts differ from +/// the BAP admin API pattern (GCC does not share the commercial host here). +/// +internal static class PowerAppsEndpointProvider +{ + /// + /// The connector API validates the same audience as the BAP admin API, + /// so one cached token serves both. + /// + public static readonly Uri Audience = BapEndpointProvider.PowerAppsAudience; + + public const string ApiVersion = "2016-11-01"; + + public static Uri GetApiBaseUri(CloudInstance cloud) + => cloud switch + { + CloudInstance.Public => new Uri("https://api.powerapps.com/"), + _ => throw new NotSupportedException( + $"The Power Apps connector API is not wired for cloud '{cloud}' in this release."), + }; +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorCatalogServiceTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorCatalogServiceTests.cs new file mode 100644 index 00000000..e71595da --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorCatalogServiceTests.cs @@ -0,0 +1,269 @@ +using System.Net; +using System.Net.Http; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.PowerPlatform; + +public sealed class ConnectorCatalogServiceTests +{ + private static readonly Guid EnvironmentId = Guid.Parse("11111111-2222-3333-4444-555555555555"); + + [Fact] + public async Task ListConnectorsAsync_UsesPowerAppsAudience_AndEnvironmentFilter() + { + var tokens = new FakeAccessTokenService(); + Uri? requestedUri = null; + var http = new FakeHttpClientFactoryWrapper(request => + { + requestedUri = request.RequestUri; + return Json("""{"value":[{"name":"shared_teams","properties":{"displayName":"Microsoft Teams","tier":"Standard"}}]}"""); + }); + + var sut = CreateService(tokens, http); + var connectors = await sut.ListConnectorsAsync(null, CancellationToken.None); + + Assert.Equal(new Uri("https://service.powerapps.com/"), tokens.LastResourceUri); + Assert.NotNull(requestedUri); + Assert.StartsWith("https://api.powerapps.com/providers/Microsoft.PowerApps/apis", requestedUri!.AbsoluteUri); + Assert.Contains("api-version=2016-11-01", requestedUri.Query); + Assert.Contains(Uri.EscapeDataString($"environment eq '{EnvironmentId:D}'"), requestedUri.Query); + + var connector = Assert.Single(connectors); + Assert.Equal("shared_teams", connector.Name); + Assert.Equal("Microsoft Teams", connector.DisplayName); + Assert.Equal("Standard", connector.Tier); + Assert.False(connector.IsCustomApi); + } + + [Fact] + public async Task ListConnectorsAsync_FollowsNextLink_AndSortsByName() + { + var page1 = """{"value":[{"name":"shared_zendesk","properties":{"displayName":"Zendesk"}}],"nextLink":"https://api.powerapps.com/providers/Microsoft.PowerApps/apis?page=2"}"""; + var page2 = """{"value":[{"name":"shared_approvals","properties":{"displayName":"Approvals","isCustomApi":true}}]}"""; + + var calls = 0; + var http = new FakeHttpClientFactoryWrapper(_ => Json(++calls == 1 ? page1 : page2)); + + var sut = CreateService(new FakeAccessTokenService(), http); + var connectors = await sut.ListConnectorsAsync(null, CancellationToken.None); + + Assert.Equal(2, calls); + Assert.Equal(["shared_approvals", "shared_zendesk"], connectors.Select(c => c.Name).ToList()); + Assert.True(connectors[0].IsCustomApi); + } + + [Fact] + public async Task GetOperationAsync_ParsesInlineSwagger_AndBuildsApiId() + { + Uri? requestedUri = null; + var http = new FakeHttpClientFactoryWrapper(request => + { + requestedUri = request.RequestUri; + return Json(""" + { + "name": "shared_test", + "properties": { + "swagger": { + "paths": { + "/items": { + "post": { + "operationId": "CreateItem", + "parameters": [ { "name": "folder", "in": "query", "type": "string", "required": true } ] + } + } + } + } + } + } + """); + }); + + var sut = CreateService(new FakeAccessTokenService(), http); + var detail = await sut.GetOperationAsync(null, "shared_test", "CreateItem", CancellationToken.None); + + Assert.NotNull(detail); + Assert.Contains("$expand=properties.swagger", requestedUri!.Query, StringComparison.OrdinalIgnoreCase); + Assert.Equal("/providers/Microsoft.PowerApps/apis/shared_test", detail.ApiId); + Assert.Equal("CreateItem", detail.OperationId); + var parameter = Assert.Single(detail.Parameters); + Assert.Equal("folder", parameter.Name); + } + + [Fact] + public async Task GetOperationAsync_FallsBackToApiDefinitionsBlob_WithoutAuthorizationHeader() + { + var blobRequests = new List(); + var http = new FakeHttpClientFactoryWrapper(request => + { + if (request.RequestUri!.Host == "blob.example.com") + { + blobRequests.Add(request); + return Json("""{"paths":{"/x":{"get":{"operationId":"GetX"}}}}"""); + } + + return Json("""{"name":"shared_test","properties":{"apiDefinitions":{"originalSwaggerUrl":"https://blob.example.com/swagger.json?sig=abc"}}}"""); + }); + + var sut = CreateService(new FakeAccessTokenService(), http); + var operations = await sut.ListOperationsAsync(null, "shared_test", CancellationToken.None); + + var operation = Assert.Single(operations); + Assert.Equal("GetX", operation.OperationId); + var blobRequest = Assert.Single(blobRequests); + Assert.Null(blobRequest.Headers.Authorization); + } + + [Fact] + public async Task GetSwagger_Throws_WhenConnectorHasNoDefinition() + { + var http = new FakeHttpClientFactoryWrapper(_ => Json("""{"name":"shared_test","properties":{}}""")); + var sut = CreateService(new FakeAccessTokenService(), http); + + var ex = await Assert.ThrowsAsync( + () => sut.ListOperationsAsync(null, "shared_test", CancellationToken.None)); + Assert.Contains("no OpenAPI definition", ex.Message); + } + + [Fact] + public async Task GetSwagger_ThrowsFriendlyError_WhenPropertiesIsNull() + { + var http = new FakeHttpClientFactoryWrapper(_ => Json("""{"name":"shared_test","properties":null}""")); + var sut = CreateService(new FakeAccessTokenService(), http); + + var ex = await Assert.ThrowsAsync( + () => sut.ListOperationsAsync(null, "shared_test", CancellationToken.None)); + Assert.Contains("no OpenAPI definition", ex.Message); + } + + [Fact] + public async Task GetSwagger_ThrowsNotFoundHint_On404() + { + var http = new FakeHttpClientFactoryWrapper(_ => new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{}"), + }); + var sut = CreateService(new FakeAccessTokenService(), http); + + var ex = await Assert.ThrowsAsync( + () => sut.ListOperationsAsync(null, "shared_missing", CancellationToken.None)); + Assert.Contains("was not found in the environment", ex.Message); + Assert.Contains("connector list", ex.Message); + } + + [Fact] + public async Task ListConnectorsAsync_ResolvesRelativeNextLink_AgainstBaseUri() + { + var requests = new List(); + var http = new FakeHttpClientFactoryWrapper(request => + { + requests.Add(request.RequestUri!); + return Json(requests.Count == 1 + ? """{"value":[],"nextLink":"providers/Microsoft.PowerApps/apis?page=2"}""" + : """{"value":[]}"""); + }); + + var sut = CreateService(new FakeAccessTokenService(), http); + await sut.ListConnectorsAsync(null, CancellationToken.None); + + Assert.Equal(2, requests.Count); + Assert.Equal("https://api.powerapps.com/providers/Microsoft.PowerApps/apis?page=2", requests[1].AbsoluteUri); + } + + [Fact] + public async Task ListConnectorsAsync_Throws_WithTruncatedBody_OnHttpFailure() + { + var http = new FakeHttpClientFactoryWrapper(_ => new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("denied"), + }); + + var sut = CreateService(new FakeAccessTokenService(), http); + + var ex = await Assert.ThrowsAsync( + () => sut.ListConnectorsAsync(null, CancellationToken.None)); + Assert.Contains("403", ex.Message); + Assert.Contains("denied", ex.Message); + } + + private static ConnectorCatalogService CreateService(FakeAccessTokenService tokens, FakeHttpClientFactoryWrapper http) + => new( + new FakeResolver(new ResolvedProfileContext( + Profile: null, + Connection: new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + Cloud = CloudInstance.Public, + EnvironmentId = EnvironmentId, + }, + Credential: new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, + Source: ResolutionSource.Global)), + new ThrowingCatalog(), + tokens, + http); + + private static HttpResponseMessage Json(string body) + => new(HttpStatusCode.OK) { Content = new StringContent(body) }; + + private sealed class FakeResolver : IConfigurationResolver + { + private readonly ResolvedProfileContext _ctx; + + public FakeResolver(ResolvedProfileContext ctx) => _ctx = ctx; + + public Task ResolveAsync(string? profileName, CancellationToken ct) + => Task.FromResult(_ctx); + } + + // EnvironmentId is stored on the connection, so the catalog must never be hit. + private sealed class ThrowingCatalog : IPowerPlatformEnvironmentCatalog + { + public Task> ListAsync( + Connection connection, Credential credential, CancellationToken ct) + => throw new InvalidOperationException("Catalog should not be called."); + + public Task TryGetByEnvironmentUrlAsync( + Connection connection, Credential credential, Uri environmentUrl, CancellationToken ct) + => throw new InvalidOperationException("Catalog should not be called."); + } + + private sealed class FakeAccessTokenService : IAccessTokenService + { + public Uri? LastResourceUri { get; private set; } + + public Task AcquireForResourceAsync(Connection connection, Credential credential, Uri resourceUri, CancellationToken ct) + { + LastResourceUri = resourceUri; + return Task.FromResult("token"); + } + } + + private sealed class FakeHttpClientFactoryWrapper : IHttpClientFactoryWrapper + { + private readonly Func _handler; + + public FakeHttpClientFactoryWrapper(Func handler) + { + _handler = handler; + } + + public HttpClient Create() => new(new FakeHttpMessageHandler(_handler)); + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public FakeHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_handler(request)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorSwaggerParserTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorSwaggerParserTests.cs new file mode 100644 index 00000000..bfbe7ce3 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorSwaggerParserTests.cs @@ -0,0 +1,330 @@ +using System.Text.Json; +using TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.PowerPlatform; + +public sealed class ConnectorSwaggerParserTests +{ + private const string Fixture = """ + { + "swagger": "2.0", + "paths": { + "/items": { + "post": { + "operationId": "CreateItem", + "summary": "Create an item", + "parameters": [ + { "name": "folder", "in": "query", "type": "string", "required": true, "x-ms-summary": "Folder", "x-ms-dynamic-values": { "operationId": "ListFolders" } }, + { "name": "secret", "in": "header", "type": "string", "x-ms-visibility": "internal" }, + { "name": "item", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Item" } } + ] + } + }, + "/trigger-poll": { + "get": { "operationId": "WhenItemChanges", "summary": "Poll trigger", "x-ms-trigger": "batch" } + }, + "/trigger-hook": { + "x-ms-notification-content": { "schema": { "type": "object" } }, + "post": { "operationId": "WhenHookFires", "summary": "Webhook trigger", "x-ms-trigger": "single", "x-ms-visibility": "important" } + } + }, + "definitions": { + "Item": { + "type": "object", + "required": ["subject"], + "properties": { + "subject": { "type": "string", "x-ms-summary": "Subject" }, + "priority": { "type": "integer", "format": "int32", "enum": [1, 2] }, + "status": { "type": "string", "enum": ["Active", "Closed"] }, + "internalOnly": { "type": "string", "x-ms-visibility": "internal" }, + "createdOn": { "type": "string", "readOnly": true }, + "details": { + "type": "object", + "required": ["note"], + "properties": { "note": { "type": "string" } } + } + } + } + } + } + """; + + private static JsonElement ParseFixture() + { + using var document = JsonDocument.Parse(Fixture); + return document.RootElement.Clone(); + } + + [Fact] + public void ListOperations_ClassifiesActionTriggerAndWebhookTrigger() + { + var operations = ConnectorSwaggerParser.ListOperations(ParseFixture()); + + Assert.Equal(3, operations.Count); + Assert.Equal("action", operations.Single(o => o.OperationId == "CreateItem").Kind); + Assert.Equal("trigger", operations.Single(o => o.OperationId == "WhenItemChanges").Kind); + Assert.Equal("webhook-trigger", operations.Single(o => o.OperationId == "WhenHookFires").Kind); + Assert.Equal("important", operations.Single(o => o.OperationId == "WhenHookFires").Visibility); + } + + [Fact] + public void GetOperation_FlattensBodySchemaIntoSlashJoinedLeaves() + { + var detail = ConnectorSwaggerParser.GetOperation( + ParseFixture(), "shared_test", "/providers/Microsoft.PowerApps/apis/shared_test", "CreateItem"); + + Assert.NotNull(detail); + Assert.Equal("POST", detail.HttpMethod); + Assert.Equal("/items", detail.Path); + Assert.Equal("/providers/Microsoft.PowerApps/apis/shared_test", detail.ApiId); + + var names = detail.Parameters.Select(p => p.Name).ToList(); + Assert.Contains("folder", names); + Assert.Contains("item/subject", names); + Assert.Contains("item/priority", names); + Assert.Contains("item/status", names); + Assert.Contains("item/details/note", names); + Assert.DoesNotContain("secret", names); + Assert.DoesNotContain("item/internalOnly", names); + Assert.DoesNotContain("item/createdOn", names); + } + + [Fact] + public void GetOperation_PropagatesRequiredAndDynamicAndEnums() + { + var detail = ConnectorSwaggerParser.GetOperation( + ParseFixture(), "shared_test", "/api", "CreateItem")!; + + var folder = detail.Parameters.Single(p => p.Name == "folder"); + Assert.True(folder.Required); + Assert.True(folder.IsDynamic); + Assert.Equal("query", folder.In); + + var subject = detail.Parameters.Single(p => p.Name == "item/subject"); + Assert.True(subject.Required); + Assert.Equal("body", subject.In); + + // details is not listed in Item.required, so its children are optional. + var note = detail.Parameters.Single(p => p.Name == "item/details/note"); + Assert.False(note.Required); + + var priority = detail.Parameters.Single(p => p.Name == "item/priority"); + Assert.Equal("integer (int32)", priority.Type); + Assert.Equal(["1", "2"], priority.EnumValues); + + var status = detail.Parameters.Single(p => p.Name == "item/status"); + Assert.Equal(["Active", "Closed"], status.EnumValues); + } + + [Fact] + public void GetOperation_ReturnsNull_ForUnknownOperation() + { + var detail = ConnectorSwaggerParser.GetOperation(ParseFixture(), "shared_test", "/api", "DoesNotExist"); + Assert.Null(detail); + } + + [Fact] + public void GetOperation_MatchesOperationIdCaseInsensitively() + { + var detail = ConnectorSwaggerParser.GetOperation(ParseFixture(), "shared_test", "/api", "createitem"); + Assert.NotNull(detail); + Assert.Equal("CreateItem", detail.OperationId); + } + + [Fact] + public void GetOperation_DeduplicatesPathItemParameters_OperationLevelWins() + { + const string fixture = """ + { + "swagger": "2.0", + "paths": { + "/items/{id}": { + "parameters": [ + { "name": "id", "in": "path", "type": "string", "required": true, "description": "path-level" } + ], + "get": { + "operationId": "GetItem", + "parameters": [ + { "name": "id", "in": "path", "type": "string", "required": true, "description": "operation-level" } + ] + } + } + } + } + """; + + using var document = JsonDocument.Parse(fixture); + var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "GetItem")!; + + var id = Assert.Single(detail.Parameters); + Assert.Equal("operation-level", id.Description); + } + + [Fact] + public void GetOperation_EmitsBodyMarker_WhenFlattenYieldsNothing_EvenWithOtherParameters() + { + const string fixture = """ + { + "swagger": "2.0", + "paths": { + "/items": { + "post": { + "operationId": "CreateItem", + "parameters": [ + { "name": "q", "in": "query", "type": "string" }, + { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "properties": { "hidden": { "type": "string", "x-ms-visibility": "internal" } } } } + ] + } + } + } + } + """; + + using var document = JsonDocument.Parse(fixture); + var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateItem")!; + + Assert.Contains(detail.Parameters, p => p.Name == "q"); + Assert.Contains(detail.Parameters, p => p.Name == "item" && p.In == "body"); + } + + [Fact] + public void GetOperation_MarksNestedDynamicSchemaProperty_AsDynamic() + { + const string fixture = """ + { + "swagger": "2.0", + "paths": { + "/rows": { + "post": { + "operationId": "CreateRow", + "parameters": [ + { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "properties": { "row": { "type": "object", "x-ms-dynamic-schema": { "operationId": "GetSchema" } } } } } + ] + } + } + } + } + """; + + using var document = JsonDocument.Parse(fixture); + var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateRow")!; + + var row = detail.Parameters.Single(p => p.Name == "item/row"); + Assert.Equal("dynamic", row.Type); + Assert.True(row.IsDynamic); + } + + [Fact] + public void GetOperation_MergesAllOfBranches() + { + const string fixture = """ + { + "swagger": "2.0", + "paths": { + "/items": { + "post": { + "operationId": "CreateItem", + "parameters": [ + { "name": "item", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Derived" } } + ] + } + } + }, + "definitions": { + "Base": { "type": "object", "properties": { "id": { "type": "string" } } }, + "Derived": { "allOf": [ { "$ref": "#/definitions/Base" } ], "properties": { "name": { "type": "string" } } } + } + } + """; + + using var document = JsonDocument.Parse(fixture); + var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateItem")!; + + var names = detail.Parameters.Select(p => p.Name).ToList(); + Assert.Contains("item/id", names); + Assert.Contains("item/name", names); + } + + [Fact] + public void GetOperation_MarksDepthCappedObjects_AsTruncated() + { + const string fixture = """ + { + "swagger": "2.0", + "paths": { + "/deep": { + "post": { + "operationId": "Deep", + "parameters": [ + { "name": "item", "in": "body", "schema": { "type": "object", "properties": { "l1": { "type": "object", "properties": { "l2": { "type": "object", "properties": { "l3": { "type": "object", "properties": { "l4": { "type": "object", "properties": { "l5": { "type": "object", "properties": { "leaf": { "type": "string" } } } } } } } } } } } } } } + ] + } + } + } + } + """; + + using var document = JsonDocument.Parse(fixture); + var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "Deep")!; + + var capped = detail.Parameters.Single(); + Assert.Equal("object (truncated)", capped.Type); + } + + [Fact] + public void GetOperation_TreatsDynamicProperties_AsDynamicSchema() + { + const string fixture = """ + { + "swagger": "2.0", + "paths": { + "/rows": { + "post": { + "operationId": "CreateRow", + "parameters": [ + { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "x-ms-dynamic-properties": { "operationId": "GetSchema" } } } + ] + } + } + } + } + """; + + using var document = JsonDocument.Parse(fixture); + var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateRow")!; + + var parameter = Assert.Single(detail.Parameters); + Assert.Equal("dynamic", parameter.Type); + Assert.True(parameter.IsDynamic); + } + + [Fact] + public void GetOperation_EmitsSingleDynamicParameter_ForDynamicSchema() + { + const string dynamicFixture = """ + { + "swagger": "2.0", + "paths": { + "/rows": { + "post": { + "operationId": "CreateRow", + "parameters": [ + { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "x-ms-dynamic-schema": { "operationId": "GetSchema" } } } + ] + } + } + } + } + """; + + using var document = JsonDocument.Parse(dynamicFixture); + var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateRow")!; + + var parameter = Assert.Single(detail.Parameters); + Assert.Equal("item", parameter.Name); + Assert.Equal("dynamic", parameter.Type); + Assert.True(parameter.IsDynamic); + Assert.True(parameter.Required); + } +}