@uipath/maestro-builder-sdk 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/bindings-v2.d.ts +37 -0
- package/dist/bindings-v2.js +155 -0
- package/dist/bindings.d.ts +6 -0
- package/dist/bindings.js +6 -0
- package/dist/bpmn/bpmn-expr-check.d.ts +41 -0
- package/dist/bpmn/bpmn-expr-check.js +218 -0
- package/dist/bpmn/bpmn-sdk.d.ts +2257 -0
- package/dist/bpmn/bpmn-sdk.js +1476 -0
- package/dist/bpmn/check-cli.d.ts +3 -0
- package/dist/bpmn/check-cli.js +32 -0
- package/dist/bpmn/check.d.ts +25 -0
- package/dist/bpmn/check.js +430 -0
- package/dist/bpmn/compile-cli.d.ts +3 -0
- package/dist/bpmn/compile-cli.js +60 -0
- package/dist/bpmn/connector.d.ts +56 -0
- package/dist/bpmn/connector.js +199 -0
- package/dist/bpmn/decompile-cli.d.ts +8 -0
- package/dist/bpmn/decompile-cli.js +54 -0
- package/dist/bpmn/decompile.d.ts +6 -0
- package/dist/bpmn/decompile.js +1108 -0
- package/dist/bpmn/format-profile.d.ts +27 -0
- package/dist/bpmn/format-profile.js +11 -0
- package/dist/bpmn/intsvc.d.ts +77 -0
- package/dist/bpmn/intsvc.js +155 -0
- package/dist/bpmn/load.d.ts +4 -0
- package/dist/bpmn/load.js +22 -0
- package/dist/bpmn/merge-cli.d.ts +3 -0
- package/dist/bpmn/merge-cli.js +45 -0
- package/dist/bpmn/merge.d.ts +19 -0
- package/dist/bpmn/merge.js +293 -0
- package/dist/bpmn/registry-types.json +1869 -0
- package/dist/bpmn/serialize.d.ts +28 -0
- package/dist/bpmn/serialize.js +757 -0
- package/dist/bpmn/tidy-cli.d.ts +2 -0
- package/dist/bpmn/tidy-cli.js +39 -0
- package/dist/bpmn/typed-node.d.ts +382 -0
- package/dist/bpmn/typed-node.js +551 -0
- package/dist/bpmn/uipath-moddle.v1.json +823 -0
- package/dist/bpmn/xml-ranges.d.ts +45 -0
- package/dist/bpmn/xml-ranges.js +151 -0
- package/dist/case/case-expr-check.d.ts +35 -0
- package/dist/case/case-expr-check.js +85 -0
- package/dist/case/case-sdk.d.ts +1282 -0
- package/dist/case/case-sdk.js +923 -0
- package/dist/case/check-cli.d.ts +3 -0
- package/dist/case/check-cli.js +31 -0
- package/dist/case/check.d.ts +18 -0
- package/dist/case/check.js +456 -0
- package/dist/case/compile-cli.d.ts +3 -0
- package/dist/case/compile-cli.js +291 -0
- package/dist/case/decompile-cli.d.ts +9 -0
- package/dist/case/decompile-cli.js +117 -0
- package/dist/case/decompile.d.ts +187 -0
- package/dist/case/decompile.js +1170 -0
- package/dist/case/entry-points.d.ts +79 -0
- package/dist/case/entry-points.js +118 -0
- package/dist/case/format-profile.d.ts +60 -0
- package/dist/case/format-profile.js +79 -0
- package/dist/case/ids.d.ts +24 -0
- package/dist/case/ids.js +76 -0
- package/dist/case/load.d.ts +12 -0
- package/dist/case/load.js +32 -0
- package/dist/case/preserve.d.ts +133 -0
- package/dist/case/preserve.js +105 -0
- package/dist/case/serialize.d.ts +10 -0
- package/dist/case/serialize.js +1414 -0
- package/dist/check-cli.d.ts +3 -0
- package/dist/check-cli.js +118 -0
- package/dist/check.d.ts +146 -0
- package/dist/check.js +4898 -0
- package/dist/cli/commands/bpmn.d.ts +2 -0
- package/dist/cli/commands/bpmn.js +73 -0
- package/dist/cli/commands/case.d.ts +2 -0
- package/dist/cli/commands/case.js +60 -0
- package/dist/cli/commands/flow.d.ts +2 -0
- package/dist/cli/commands/flow.js +99 -0
- package/dist/cli/commands/registry.d.ts +10 -0
- package/dist/cli/commands/registry.js +170 -0
- package/dist/cli/common.d.ts +30 -0
- package/dist/cli/common.js +92 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli-run.d.ts +2 -0
- package/dist/cli-run.js +42 -0
- package/dist/compile-cli.d.ts +3 -0
- package/dist/compile-cli.js +160 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +201 -0
- package/dist/core/actions.d.ts +3244 -0
- package/dist/core/actions.js +1062 -0
- package/dist/core/binding-messages.d.ts +1 -0
- package/dist/core/binding-messages.js +16 -0
- package/dist/core/bindings.d.ts +12 -0
- package/dist/core/bindings.js +67 -0
- package/dist/core/ceql.d.ts +73 -0
- package/dist/core/ceql.js +272 -0
- package/dist/core/cli-spelling.d.ts +39 -0
- package/dist/core/cli-spelling.js +62 -0
- package/dist/core/connector-raw-node.d.ts +21 -0
- package/dist/core/connector-raw-node.js +47 -0
- package/dist/core/connectors.d.ts +184 -0
- package/dist/core/connectors.js +128 -0
- package/dist/core/expr-check.d.ts +93 -0
- package/dist/core/expr-check.js +186 -0
- package/dist/core/expr.d.ts +323 -0
- package/dist/core/expr.js +400 -0
- package/dist/core/index.d.ts +10 -0
- package/dist/core/index.js +10 -0
- package/dist/core/library.d.ts +440 -0
- package/dist/core/library.js +1183 -0
- package/dist/core/list-envelope.d.ts +45 -0
- package/dist/core/list-envelope.js +97 -0
- package/dist/core/lookup-store.d.ts +22 -0
- package/dist/core/lookup-store.js +85 -0
- package/dist/core/lookups.d.ts +227 -0
- package/dist/core/lookups.js +231 -0
- package/dist/core/node-classes.d.ts +234 -0
- package/dist/core/node-classes.js +158 -0
- package/dist/core/script-return.d.ts +68 -0
- package/dist/core/script-return.js +197 -0
- package/dist/core/stable-id.d.ts +28 -0
- package/dist/core/stable-id.js +37 -0
- package/dist/core-definitions.json +16249 -0
- package/dist/decompile-cli.d.ts +3 -0
- package/dist/decompile-cli.js +131 -0
- package/dist/decompile.d.ts +138 -0
- package/dist/decompile.js +2482 -0
- package/dist/emit.d.ts +10 -0
- package/dist/emit.js +60 -0
- package/dist/event-filters.d.ts +130 -0
- package/dist/event-filters.js +137 -0
- package/dist/flow-expr-check.d.ts +17 -0
- package/dist/flow-expr-check.js +193 -0
- package/dist/flow-sdk.d.ts +1341 -0
- package/dist/flow-sdk.js +1164 -0
- package/dist/flow-variables.d.ts +13 -0
- package/dist/flow-variables.js +42 -0
- package/dist/format-profile.d.ts +72 -0
- package/dist/format-profile.js +97 -0
- package/dist/generators/_connections.py +287 -0
- package/dist/generators/_events.py +355 -0
- package/dist/generators/_lookups.py +114 -0
- package/dist/generators/_naming.py +185 -0
- package/dist/generators/_objects.py +299 -0
- package/dist/generators/_resolve.py +273 -0
- package/dist/generators/convert_library_to_md.py +1441 -0
- package/dist/generators/extract_library.py +125 -0
- package/dist/generators/generate_connectors_ts.py +936 -0
- package/dist/generators/generate_library_json.py +2527 -0
- package/dist/generators/generate_triggers_ts.py +110 -0
- package/dist/generators/materialize_registry.py +155 -0
- package/dist/generators/prepare_connector.py +1688 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/ixp-definition.json +1982 -0
- package/dist/library.d.ts +6 -0
- package/dist/library.js +6 -0
- package/dist/load.d.ts +16 -0
- package/dist/load.js +95 -0
- package/dist/merge-cli.d.ts +3 -0
- package/dist/merge-cli.js +47 -0
- package/dist/merge.d.ts +125 -0
- package/dist/merge.js +0 -0
- package/dist/migrate.d.ts +15 -0
- package/dist/migrate.js +245 -0
- package/dist/node-runtime.d.ts +3 -0
- package/dist/node-runtime.js +22 -0
- package/dist/package-name.d.ts +17 -0
- package/dist/package-name.js +17 -0
- package/dist/ref-resolve.d.ts +77 -0
- package/dist/ref-resolve.js +170 -0
- package/dist/registry/cache.d.ts +55 -0
- package/dist/registry/cache.js +134 -0
- package/dist/registry/curated-overlay.d.ts +16 -0
- package/dist/registry/curated-overlay.js +62 -0
- package/dist/registry/extractor.d.ts +22 -0
- package/dist/registry/extractor.js +18 -0
- package/dist/registry/materialize-bin.d.ts +2 -0
- package/dist/registry/materialize-bin.js +7 -0
- package/dist/registry/materialize-cli.d.ts +1 -0
- package/dist/registry/materialize-cli.js +46 -0
- package/dist/registry/materializer.d.ts +23 -0
- package/dist/registry/materializer.js +34 -0
- package/dist/registry/path-cli.d.ts +1 -0
- package/dist/registry/path-cli.js +56 -0
- package/dist/registry/prepare-cli.d.ts +62 -0
- package/dist/registry/prepare-cli.js +185 -0
- package/dist/registry/pull-cli.d.ts +1 -0
- package/dist/registry/pull-cli.js +274 -0
- package/dist/registry/python.d.ts +26 -0
- package/dist/registry/python.js +46 -0
- package/dist/registry/search-cli.d.ts +52 -0
- package/dist/registry/search-cli.js +159 -0
- package/dist/schema-version.d.ts +165 -0
- package/dist/schema-version.js +148 -0
- package/dist/serialize.d.ts +64 -0
- package/dist/serialize.js +6398 -0
- package/lib/README.md +112 -0
- package/lib/library-json.samples/index.json +158 -0
- package/lib/library-json.samples/invariant-collections.json +83 -0
- package/lib/library-json.samples/list-envelopes.json +123 -0
- package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.json +131 -0
- package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.v1def.json +306 -0
- package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.json +86 -0
- package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.v1def.json +208 -0
- package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.json +2040 -0
- package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.v1def.json +195 -0
- package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.json +169 -0
- package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.v1def.json +209 -0
- package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.json +63 -0
- package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.v1def.json +262 -0
- package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.json +63 -0
- package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.v1def.json +213 -0
- package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.json +227 -0
- package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.v1def.json +373 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.json +116 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.v1def.json +369 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.json +56 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.v1def.json +256 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.json +116 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.v1def.json +320 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.json +56 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.v1def.json +207 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.json +107 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.v1def.json +264 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.json +142 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.v1def.json +306 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.json +107 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.v1def.json +217 -0
- package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.json +85 -0
- package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.v1def.json +209 -0
- package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.json +755 -0
- package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.v1def.json +513 -0
- package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.json +122 -0
- package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.v1def.json +227 -0
- package/package.json +85 -0
|
@@ -0,0 +1,2527 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate the canonical connector library from the UiPath Flow registry.
|
|
4
|
+
|
|
5
|
+
For each enabled, cross-platform, non-experimental connector node type this
|
|
6
|
+
script writes a single JSON file under `--output-dir` (default: `./library/`)
|
|
7
|
+
with the shape below. The output is language-neutral and round-trips with
|
|
8
|
+
Flow definitions: every field a Flow node needs is either in this file
|
|
9
|
+
or derivable from a flow's per-node manifest.
|
|
10
|
+
|
|
11
|
+
library/
|
|
12
|
+
<connector-key>/
|
|
13
|
+
<action-id>@<version>.json # curated, e.g. create-issue@1.0.0.json
|
|
14
|
+
<action-id>@<version>.v1def.json # Flow definitions[] sidecar
|
|
15
|
+
generic/
|
|
16
|
+
<object>/
|
|
17
|
+
<op>@<version>.json # Generic CRUD expanded per
|
|
18
|
+
<op>@<version>.v1def.json # (object, operation) pair
|
|
19
|
+
...
|
|
20
|
+
index.json # flat list of every entry, for tooling
|
|
21
|
+
|
|
22
|
+
Per-entry shape:
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
"schemaVersion": "1",
|
|
26
|
+
"nodeType": "uipath.connector.uipath-microsoft-github.create-issue",
|
|
27
|
+
"version": "1.0.0",
|
|
28
|
+
"category": "...",
|
|
29
|
+
"tags": ["connector"],
|
|
30
|
+
"connector": { "key": "uipath-microsoft-github" },
|
|
31
|
+
"operation": {
|
|
32
|
+
"name": "Create",
|
|
33
|
+
"objectName": "create_issues",
|
|
34
|
+
"httpMethod": "POST",
|
|
35
|
+
"subType": "standard",
|
|
36
|
+
"supportsStreaming": false
|
|
37
|
+
},
|
|
38
|
+
"display": { "label": "...", "description": "...", "icon": "...",
|
|
39
|
+
"iconBackground": "...", "iconBackgroundDark": "..." },
|
|
40
|
+
"runtime": {
|
|
41
|
+
"bpmnType": "bpmn:SendTask",
|
|
42
|
+
"serviceType": "Intsvc.ActivityExecution",
|
|
43
|
+
"activityConfigurationVersion": "1.0.0",
|
|
44
|
+
"requiresConnection": true,
|
|
45
|
+
"requiresFolderKey": true
|
|
46
|
+
},
|
|
47
|
+
"inputSchema": { "fields": [ ... raw registry shape ... ] },
|
|
48
|
+
"outputSchema": { "fields": [ ... ] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
The script keeps the CLI plumbing (registry search, parallel get, on-disk
|
|
52
|
+
cache, resume support) from the cs2fil ancestor unchanged — only the
|
|
53
|
+
extraction and write phases differ.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
python3 generate_library_json.py [options]
|
|
57
|
+
|
|
58
|
+
Options:
|
|
59
|
+
--output-dir PATH Output directory (default: ./library)
|
|
60
|
+
--cache-dir PATH Cache for raw `flow registry get` JSONs
|
|
61
|
+
(default: ./.registry-cache)
|
|
62
|
+
--is-cache-dir PATH Cache for raw `is resources describe` JSONs
|
|
63
|
+
(default: ./.is-cache)
|
|
64
|
+
--skip-enrichment Skip the second pass that calls
|
|
65
|
+
`uip is resources describe` per entry
|
|
66
|
+
--keep-temp Keep cache directories after run
|
|
67
|
+
--catalog-scope SCOPE Connector catalog scope: all, tier1, or tier2
|
|
68
|
+
(default: all). Tiers are resolved live from
|
|
69
|
+
`uip is connectors metadata <connector-key>`.
|
|
70
|
+
--concurrency N Parallel remote CLI calls (default: 10)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
import argparse
|
|
74
|
+
import hashlib
|
|
75
|
+
import json
|
|
76
|
+
import os
|
|
77
|
+
import re
|
|
78
|
+
import shutil
|
|
79
|
+
import subprocess
|
|
80
|
+
import sys
|
|
81
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
85
|
+
import _events # noqa: E402
|
|
86
|
+
|
|
87
|
+
SCHEMA_VERSION = "1"
|
|
88
|
+
|
|
89
|
+
# When set (by prepare_connector's single-op, connection-scoped path), every
|
|
90
|
+
# `uip flow registry get` / `is resources describe` / `is resources list` call
|
|
91
|
+
# appends `--connection-id <id>`. The bulk generator leaves this None (it runs
|
|
92
|
+
# connection-less, yielding the thin schema-dynamic entries C4 later overlays).
|
|
93
|
+
_CONNECTION_ID = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def set_connection_id(connection_id):
|
|
97
|
+
"""Route all subsequent uip calls through this connection (see C4 / §E)."""
|
|
98
|
+
global _CONNECTION_ID
|
|
99
|
+
_CONNECTION_ID = connection_id
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _conn_args():
|
|
103
|
+
"""`['--connection-id', <id>]` when a connection is set, else `[]`."""
|
|
104
|
+
return ["--connection-id", _CONNECTION_ID] if _CONNECTION_ID else []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_PARENT_FIELDS = {}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def set_parent_fields(fields):
|
|
111
|
+
"""Parent-field VALUES forwarded to `is resources describe -f name=value`.
|
|
112
|
+
|
|
113
|
+
A schema-dynamic operation resolves its real field set from the values of a
|
|
114
|
+
few parent fields, not from the connection alone. Jira `create-issue`, live
|
|
115
|
+
against one tenant: no `-f` describes to 2 fields (the parents themselves);
|
|
116
|
+
project `IN` + issue type Task describes to 19; the same project with Epic
|
|
117
|
+
describes to a DIFFERENT 22; a different project with its own Task describes
|
|
118
|
+
to 15. Both dimensions matter independently.
|
|
119
|
+
|
|
120
|
+
Too few parents is refused outright ("No api-type ObjectAction matched for
|
|
121
|
+
fields [...]"); a complete set carrying a value the connection does not have
|
|
122
|
+
is accepted and resolves back to just the parents. See
|
|
123
|
+
`failed_field_describes` for why the first needs carrying out of band.
|
|
124
|
+
"""
|
|
125
|
+
global _PARENT_FIELDS
|
|
126
|
+
_PARENT_FIELDS = dict(fields or {})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _field_args():
|
|
130
|
+
"""`['-f', 'name=value', ...]` for the parent fields set, else `[]`."""
|
|
131
|
+
args = []
|
|
132
|
+
for name, value in _PARENT_FIELDS.items():
|
|
133
|
+
args += ["-f", f"{name}={value}"]
|
|
134
|
+
return args
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_FAILED_FIELD_DESCRIBES = []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def reset_failed_field_describes():
|
|
141
|
+
"""Clear the record of describes that failed while parent fields were set."""
|
|
142
|
+
del _FAILED_FIELD_DESCRIBES[:]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def failed_field_describes():
|
|
146
|
+
"""The CLI's own `Message` for each describe that failed under `-f`.
|
|
147
|
+
|
|
148
|
+
Recorded only while parent fields are set, because that is when a failure is
|
|
149
|
+
diagnostic rather than ordinary catalog noise. An incomplete parent set is
|
|
150
|
+
rejected outright and helpfully:
|
|
151
|
+
|
|
152
|
+
Result: Failure
|
|
153
|
+
Message: No api-type ObjectAction matched for fields
|
|
154
|
+
[fields.project.key] on operation 'Create'
|
|
155
|
+
|
|
156
|
+
but the pipeline swallows it — `is_describe_operation` maps any non-Success
|
|
157
|
+
to `None`, and `enrich_entry` then returns the entry with its registry-
|
|
158
|
+
DECLARED fields intact. The caller sees a plausible small schema and no
|
|
159
|
+
error at all, so the good message has to be carried out of band.
|
|
160
|
+
"""
|
|
161
|
+
return list(_FAILED_FIELD_DESCRIBES)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _note_failed_field_describe(connector_key, object_name, operation, message):
|
|
165
|
+
if _PARENT_FIELDS:
|
|
166
|
+
_FAILED_FIELD_DESCRIBES.append(
|
|
167
|
+
(connector_key, object_name, operation, message or "")
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _field_scope():
|
|
172
|
+
"""Cache discriminator for the parent-field set (empty when none is set).
|
|
173
|
+
|
|
174
|
+
The describe response is a function of these values, so a cache keyed only
|
|
175
|
+
on (connection, object, operation) would serve the first variant's fields
|
|
176
|
+
for every later one — the whole point of preparing per issue type.
|
|
177
|
+
"""
|
|
178
|
+
if not _PARENT_FIELDS:
|
|
179
|
+
return ""
|
|
180
|
+
flat = "\0".join(f"{k}={v}" for k, v in sorted(_PARENT_FIELDS.items()))
|
|
181
|
+
return "f-" + hashlib.sha256(flat.encode()).hexdigest()[:12]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# CLI
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def parse_args():
|
|
189
|
+
p = argparse.ArgumentParser(
|
|
190
|
+
description="Generate the canonical connector library from the UiPath Flow registry"
|
|
191
|
+
)
|
|
192
|
+
p.add_argument("--output-dir", default="./library",
|
|
193
|
+
help="Output directory (default: ./library)")
|
|
194
|
+
p.add_argument("--cache-dir", default="./.registry-cache",
|
|
195
|
+
help="Cache for `flow registry get` JSONs (default: ./.registry-cache)")
|
|
196
|
+
p.add_argument("--is-cache-dir", default="./.is-cache",
|
|
197
|
+
help="Cache for `is resources describe` JSONs (default: ./.is-cache)")
|
|
198
|
+
p.add_argument("--connectors-cache-dir", default="./.is-connectors-cache",
|
|
199
|
+
help="Cache for `is connectors metadata` JSONs "
|
|
200
|
+
"(default: ./.is-connectors-cache)")
|
|
201
|
+
p.add_argument("--skip-enrichment", action="store_true",
|
|
202
|
+
help="Skip the `is resources describe` enrichment pass")
|
|
203
|
+
p.add_argument("--skip-connector-enrichment", action="store_true",
|
|
204
|
+
help="Skip the `is connectors metadata` connector-level pass")
|
|
205
|
+
p.add_argument("--skip-generic-expansion", action="store_true",
|
|
206
|
+
help="Skip the Generic CRUD expansion pass (keeps Generic CRUD "
|
|
207
|
+
"entries as un-expanded templates instead of materializing "
|
|
208
|
+
"one entry per object/operation)")
|
|
209
|
+
p.add_argument("--triggers-output", default=None,
|
|
210
|
+
help="Path for the trigger registry side-file (default: "
|
|
211
|
+
"<parent of --output-dir>/registry-triggers.json). "
|
|
212
|
+
"This is a catalog of every IS trigger node type "
|
|
213
|
+
"found by `flow registry search`. The START form is "
|
|
214
|
+
"not materialized into the main library because "
|
|
215
|
+
"`flow registry get <trigger>` requires "
|
|
216
|
+
"--connection-id, which would gate the full library "
|
|
217
|
+
"build on the user having a connection for every "
|
|
218
|
+
"trigger-supporting connector; its WAIT twin "
|
|
219
|
+
"(`uipath.connector.event.<key>.<event>`) answers "
|
|
220
|
+
"offline and IS generated, and the SDK derives the "
|
|
221
|
+
"start form from it.")
|
|
222
|
+
p.add_argument("--keep-temp", action="store_true",
|
|
223
|
+
help="Keep cache directories after run")
|
|
224
|
+
p.add_argument("--connectors", default=None,
|
|
225
|
+
help="Comma-separated connector keys to include (e.g. "
|
|
226
|
+
"'uipath-salesforce-slack,uipath-atlassian-jira'). When set, "
|
|
227
|
+
"only these connectors' action node types are enriched + "
|
|
228
|
+
"materialized — a CURATED subset of the full registry. Avoids "
|
|
229
|
+
"the cost of enriching all ~149 connectors. Default: all.")
|
|
230
|
+
p.add_argument("--catalog-scope", choices=("all", "tier1", "tier2"),
|
|
231
|
+
default="all",
|
|
232
|
+
help="Connector catalog scope. 'tier1' and 'tier2' select "
|
|
233
|
+
"connector keys whose live `uip is connectors metadata` "
|
|
234
|
+
"Tier field is '1' or '2'; 'all' keeps the complete "
|
|
235
|
+
"catalog (default: all).")
|
|
236
|
+
p.add_argument("--concurrency", type=int, default=10,
|
|
237
|
+
help="Parallel remote CLI calls (default: 10)")
|
|
238
|
+
return p.parse_args()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Registry interaction
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def registry_search(cache_dir):
|
|
246
|
+
"""Run uip flow registry search and return list of connector node types."""
|
|
247
|
+
search_file = os.path.join(cache_dir, "_search_results.json")
|
|
248
|
+
if not os.path.exists(search_file):
|
|
249
|
+
print("Running registry search...")
|
|
250
|
+
result = subprocess.run(
|
|
251
|
+
["uip", "maestro", "flow", "registry", "search",
|
|
252
|
+
"--filter", "tags:in=connector", "--output", "json"],
|
|
253
|
+
capture_output=True, text=True
|
|
254
|
+
)
|
|
255
|
+
if result.returncode != 0:
|
|
256
|
+
print(f"Registry search failed: {result.stderr}", file=sys.stderr)
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
259
|
+
with open(search_file, "w") as f:
|
|
260
|
+
f.write(result.stdout)
|
|
261
|
+
|
|
262
|
+
with open(search_file) as f:
|
|
263
|
+
data = json.load(f)
|
|
264
|
+
|
|
265
|
+
return [
|
|
266
|
+
entry["NodeType"]
|
|
267
|
+
for entry in data.get("Data", [])
|
|
268
|
+
if entry["NodeType"].startswith("uipath.connector.")
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def write_triggers_side_file(cache_dir, output_path):
|
|
273
|
+
"""Write a flat catalog of every IS trigger nodeType in the registry.
|
|
274
|
+
|
|
275
|
+
Triggers can't be materialized into the main library because
|
|
276
|
+
`uip flow registry get <trigger>` requires `--connection-id`, and the
|
|
277
|
+
library generator can't know which connection to use for the ~70
|
|
278
|
+
different trigger-supporting connectors. The registry SEARCH does list
|
|
279
|
+
every trigger nodeType with name + description + version + tags
|
|
280
|
+
(connection-less), so we capture those into a side-file. Downstream
|
|
281
|
+
tools (the emitter, the dispatcher, the n8n classification) consult this
|
|
282
|
+
file for trigger existence/lookup; full per-trigger schema fetch is
|
|
283
|
+
deferred to author time (when the user has a connection).
|
|
284
|
+
"""
|
|
285
|
+
search_file = os.path.join(cache_dir, "_search_results.json")
|
|
286
|
+
if not os.path.exists(search_file):
|
|
287
|
+
print(f" WARN: search cache missing at {search_file}; "
|
|
288
|
+
f"can't write triggers side-file", file=sys.stderr)
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
with open(search_file) as f:
|
|
292
|
+
data = json.load(f)
|
|
293
|
+
|
|
294
|
+
triggers = []
|
|
295
|
+
for entry in data.get("Data", []) or []:
|
|
296
|
+
node_type = entry.get("NodeType", "")
|
|
297
|
+
if not node_type.startswith("uipath.connector.trigger."):
|
|
298
|
+
continue
|
|
299
|
+
parts = node_type.split(".")
|
|
300
|
+
connector_key = parts[3] if len(parts) > 3 else ""
|
|
301
|
+
triggers.append({
|
|
302
|
+
"NodeType": node_type,
|
|
303
|
+
"DisplayName": entry.get("DisplayName", ""),
|
|
304
|
+
"Description": entry.get("Description", ""),
|
|
305
|
+
"Version": entry.get("Version", ""),
|
|
306
|
+
"connectorKey": connector_key,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
triggers.sort(key=lambda t: (t["connectorKey"], t["NodeType"]))
|
|
310
|
+
|
|
311
|
+
envelope = {
|
|
312
|
+
"schemaVersion": "1",
|
|
313
|
+
"note": (
|
|
314
|
+
"Catalog of every IS trigger nodeType returned by "
|
|
315
|
+
"`uip maestro flow registry search --filter tags:in=connector`. "
|
|
316
|
+
"Full per-trigger schemas (filterFields, outputFields, eventMode) "
|
|
317
|
+
"require `--connection-id` on `uip flow registry get`, which "
|
|
318
|
+
"the library generator can't supply across all ~70 "
|
|
319
|
+
"trigger-supporting connectors. Use this side-file for the "
|
|
320
|
+
"'does an IS trigger exist for X provider?' question; fetch the "
|
|
321
|
+
"full schema at author time with `uip is triggers describe "
|
|
322
|
+
"<connector-key> <op> <object> --connection-id <id>`."
|
|
323
|
+
),
|
|
324
|
+
"count": len(triggers),
|
|
325
|
+
"triggers": triggers,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
329
|
+
with open(output_path, "w") as f:
|
|
330
|
+
json.dump(envelope, f, indent=2)
|
|
331
|
+
f.write("\n")
|
|
332
|
+
print(f" wrote {len(triggers)} triggers to {output_path}")
|
|
333
|
+
return len(triggers)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def registry_get(node_type, cache_dir, quiet=False):
|
|
337
|
+
"""Fetch a single connector's JSON, using cache if available.
|
|
338
|
+
|
|
339
|
+
`quiet` suppresses the failure WARN — for a caller probing which of
|
|
340
|
+
several node-type namespaces an action lives in, where a miss is an
|
|
341
|
+
expected answer rather than a problem to report.
|
|
342
|
+
"""
|
|
343
|
+
safe_name = node_type.replace(".", "_")
|
|
344
|
+
cache_file = os.path.join(cache_dir, f"{safe_name}.json")
|
|
345
|
+
if os.path.exists(cache_file):
|
|
346
|
+
return cache_file
|
|
347
|
+
|
|
348
|
+
result = subprocess.run(
|
|
349
|
+
["uip", "maestro", "flow", "registry", "get", node_type, "--output", "json"] + _conn_args(),
|
|
350
|
+
capture_output=True, text=True
|
|
351
|
+
)
|
|
352
|
+
if result.returncode != 0:
|
|
353
|
+
if not quiet:
|
|
354
|
+
print(f" WARN: registry get failed for {node_type}: {result.stderr.strip()}",
|
|
355
|
+
file=sys.stderr)
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
with open(cache_file, "w") as f:
|
|
359
|
+
f.write(result.stdout)
|
|
360
|
+
return cache_file
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def fetch_all(node_types, cache_dir, concurrency):
|
|
364
|
+
"""Fetch all connector JSONs in parallel."""
|
|
365
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
366
|
+
results = {}
|
|
367
|
+
already_cached = 0
|
|
368
|
+
to_fetch = []
|
|
369
|
+
|
|
370
|
+
for nt in node_types:
|
|
371
|
+
safe_name = nt.replace(".", "_")
|
|
372
|
+
cache_file = os.path.join(cache_dir, f"{safe_name}.json")
|
|
373
|
+
if os.path.exists(cache_file):
|
|
374
|
+
results[nt] = cache_file
|
|
375
|
+
already_cached += 1
|
|
376
|
+
else:
|
|
377
|
+
to_fetch.append(nt)
|
|
378
|
+
|
|
379
|
+
if already_cached:
|
|
380
|
+
print(f" {already_cached} connectors already cached, {len(to_fetch)} to fetch")
|
|
381
|
+
|
|
382
|
+
if to_fetch:
|
|
383
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
384
|
+
futures = {pool.submit(registry_get, nt, cache_dir): nt for nt in to_fetch}
|
|
385
|
+
done = 0
|
|
386
|
+
for future in as_completed(futures):
|
|
387
|
+
nt = futures[future]
|
|
388
|
+
done += 1
|
|
389
|
+
path = future.result()
|
|
390
|
+
if path:
|
|
391
|
+
results[nt] = path
|
|
392
|
+
if done % 50 == 0 or done == len(to_fetch):
|
|
393
|
+
print(f" Fetched {done}/{len(to_fetch)}")
|
|
394
|
+
|
|
395
|
+
return results
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
# Canonical extraction
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Connector entries are kept if they're enabled, cross-platform, and not
|
|
402
|
+
# experimental. We no longer filter on `activityType == "Curated"` — Generic
|
|
403
|
+
# CRUD actions and HTTP escape hatches are kept too. Curated entries land
|
|
404
|
+
# at the connector top level unchanged; Generic CRUD entries are later
|
|
405
|
+
# materialized into per-(object, operation) entries under
|
|
406
|
+
# `<connector>/generic/<object>/<op>@<ver>.json` by `expand_all_generics`.
|
|
407
|
+
|
|
408
|
+
# Ops the IS CLI exposes as a POST whose inputs are query-string params with a
|
|
409
|
+
# required (often empty) body. A dispatcher reads `operation.dispatchStyle == "query-post"`
|
|
410
|
+
# and dispatches `run create <obj> --query <inputs> --body '{}'` instead of the
|
|
411
|
+
# default create→--body. Keyed by (connectorKey, objectName). The registry does not
|
|
412
|
+
# expose per-parameter location, so this is a small explicit list rather than derived.
|
|
413
|
+
CURATED_DISPATCH_STYLE = {
|
|
414
|
+
("uipath-uipath-dataservice", "QueryEntityRecords_V3"): "query-post",
|
|
415
|
+
# The V2 curated sibling, and the one the REGISTRY node
|
|
416
|
+
# `uipath.connector.uipath-uipath-dataservice.query-entity-records` maps to:
|
|
417
|
+
# POST /v2/{entityName}/qer, every input a path or query parameter, body `{}`.
|
|
418
|
+
# Verified live 2026-07-31 — `run create … --query '{entityName,queryExpression,
|
|
419
|
+
# limit}' --body '{}'` returns the 8 BillingDisputeERP rows.
|
|
420
|
+
("uipath-uipath-dataservice", "QueryEntityRecordsCurated"): "query-post",
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
# Per-parameter value-grammar tags, keyed by (connectorKey, objectName). A dispatcher
|
|
424
|
+
# validates the COMPUTED input against `operation.paramGrammars` at every
|
|
425
|
+
# dispatch boundary — fixture replay included, so `flow-cli test` catches a
|
|
426
|
+
# malformed value offline. "sql-where": Data Service query expressions (RHS
|
|
427
|
+
# string literals must be single-quoted; an unquoted `ACCT-98201-NE` parses as
|
|
428
|
+
# subtraction server-side and 400s). Explicit list like dispatchStyle — the
|
|
429
|
+
# registry doesn't describe value grammars.
|
|
430
|
+
CURATED_PARAM_GRAMMARS = {
|
|
431
|
+
("uipath-uipath-dataservice", "QueryEntityRecords_V3"): {"queryExpression": "sql-where"},
|
|
432
|
+
("uipath-uipath-dataservice", "QueryEntityRecords"): {"queryExpression": "sql-where"},
|
|
433
|
+
("uipath-uipath-dataservice", "QueryEntityRecordsCurated"): {"queryExpression": "sql-where"},
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
# A few connector ops the registry enumerates only in a form `uip is resources run`
|
|
437
|
+
# can't dispatch. The Outlook reply op is enumerated as the MULTIPART `ReplyToEmailV2`
|
|
438
|
+
# (its `body`/`file` are `multipart` params, which a JSON `--body` can't build). The
|
|
439
|
+
# connector also exposes the non-multipart sibling `ReplyToEmail` (`id` in --query, the
|
|
440
|
+
# reply text in --body) that Flow v1 uses and a dispatcher sends via the per-parameter
|
|
441
|
+
# arg split. We curate a remap to that sibling so a clean regen reproduces the
|
|
442
|
+
# live-runnable op rather than the undispatchable multipart one. Keyed by (connectorKey,
|
|
443
|
+
# registry objectName); deep-merged into `operation` AFTER enrichment so it wins over the
|
|
444
|
+
# `is resources describe` params. The node's `inputSchema` (the reply `comment` + message
|
|
445
|
+
# fields) is left as enrichment derives it — only the query params + objectName/path change.
|
|
446
|
+
CURATED_OP_OVERRIDE = {
|
|
447
|
+
("uipath-microsoft-outlook365", "ReplyToEmailV2"): {
|
|
448
|
+
"operation": {
|
|
449
|
+
"objectName": "ReplyToEmail",
|
|
450
|
+
"path": "/ReplyToEmail",
|
|
451
|
+
"parameters": [
|
|
452
|
+
{"name": "id", "type": "query", "dataType": "string", "required": True,
|
|
453
|
+
"displayName": "Message ID",
|
|
454
|
+
"description": "The id of the email to reply to"},
|
|
455
|
+
],
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def apply_curated_overrides(entries):
|
|
462
|
+
"""Deep-merge CURATED_OP_OVERRIDE into matching entries, AFTER enrichment, so the
|
|
463
|
+
curated shape wins over the registry-described one. Matches on (connectorKey,
|
|
464
|
+
objectName) using the entry's current (registry) objectName."""
|
|
465
|
+
n = 0
|
|
466
|
+
for e in entries:
|
|
467
|
+
key = (e.get("connector", {}).get("key", ""),
|
|
468
|
+
e.get("operation", {}).get("objectName", ""))
|
|
469
|
+
override = CURATED_OP_OVERRIDE.get(key)
|
|
470
|
+
if not override:
|
|
471
|
+
continue
|
|
472
|
+
for section, fields in override.items():
|
|
473
|
+
e.setdefault(section, {})
|
|
474
|
+
for k, v in fields.items():
|
|
475
|
+
e[section][k] = v
|
|
476
|
+
n += 1
|
|
477
|
+
if n:
|
|
478
|
+
print(f" Applied {n} curated op override(s)")
|
|
479
|
+
return entries
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _context_value(context, name):
|
|
483
|
+
"""Return the `value` of a context entry by name, or None."""
|
|
484
|
+
for c in context:
|
|
485
|
+
if c.get("name") == name:
|
|
486
|
+
return c.get("value")
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _has_context(context, name):
|
|
491
|
+
return any(c.get("name") == name for c in context)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _parameter_metadata(connector_method_info):
|
|
495
|
+
"""Field-shaped metadata carried by each method parameter, keyed by name.
|
|
496
|
+
|
|
497
|
+
A method parameter and a node field describe the same input in two
|
|
498
|
+
different schemas. These are the keys that exist on both sides, so they
|
|
499
|
+
are the only ones worth moving across: `format`, `mask`, `design`,
|
|
500
|
+
`sortOrder`, `curated`, `defaultValue` and `experimental` have no
|
|
501
|
+
counterpart on a field, and a node's own field vocabulary never carries
|
|
502
|
+
them.
|
|
503
|
+
|
|
504
|
+
`reference` is the one that matters most — it names the collection a value
|
|
505
|
+
is looked up in (a Slack channel, a Drive file), and without it an author
|
|
506
|
+
has no way to turn a display name into the id the API wants.
|
|
507
|
+
"""
|
|
508
|
+
out = {}
|
|
509
|
+
for p in (connector_method_info or {}).get("parameters") or []:
|
|
510
|
+
name = p.get("name")
|
|
511
|
+
if not isinstance(name, str) or not name:
|
|
512
|
+
continue
|
|
513
|
+
meta = {}
|
|
514
|
+
if p.get("displayName") is not None:
|
|
515
|
+
meta["displayName"] = p["displayName"]
|
|
516
|
+
if p.get("reference") is not None:
|
|
517
|
+
meta["reference"] = p["reference"]
|
|
518
|
+
# A parameter's `enum` is deprecated in favour of `enhancedEnum`, whose
|
|
519
|
+
# `{name, value}` pairs flatten to the literals a field's `enum` holds.
|
|
520
|
+
if "enum" in p:
|
|
521
|
+
meta["enum"] = p["enum"]
|
|
522
|
+
elif p.get("enhancedEnum"):
|
|
523
|
+
values = [
|
|
524
|
+
e.get("value")
|
|
525
|
+
for e in p["enhancedEnum"]
|
|
526
|
+
if isinstance(e, dict) and e.get("value") is not None
|
|
527
|
+
]
|
|
528
|
+
if values:
|
|
529
|
+
meta["enum"] = values
|
|
530
|
+
if meta:
|
|
531
|
+
out[name] = meta
|
|
532
|
+
return out
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def extract_canonical(json_path):
|
|
536
|
+
"""Build a canonical-library entry from one raw registry JSON file.
|
|
537
|
+
|
|
538
|
+
Returns the entry dict, or None if the connector is filtered out
|
|
539
|
+
(not enabled, not curated, etc.).
|
|
540
|
+
"""
|
|
541
|
+
with open(json_path) as f:
|
|
542
|
+
data = json.load(f)
|
|
543
|
+
|
|
544
|
+
node = data.get("Data", {}).get("Node")
|
|
545
|
+
if not node:
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
node_type = node.get("nodeType", "")
|
|
549
|
+
model = node.get("model", {}) or {}
|
|
550
|
+
context = model.get("context", []) or []
|
|
551
|
+
|
|
552
|
+
# Activity-level metadata moved out of model.context.metadata into the
|
|
553
|
+
# editor form. Search `node.form.sections[*].fields[*].componentProps`
|
|
554
|
+
# for the `connectorDetail` block — it carries isEnabled / targetPlatform
|
|
555
|
+
# / isExperimental / configuration (a JSON string with activityType etc).
|
|
556
|
+
connector_detail = None
|
|
557
|
+
for section in (node.get("form", {}) or {}).get("sections", []) or []:
|
|
558
|
+
for f in section.get("fields", []) or []:
|
|
559
|
+
cp = (f.get("componentProps") or {}).get("connectorDetail")
|
|
560
|
+
if isinstance(cp, dict) and "configuration" in cp:
|
|
561
|
+
connector_detail = cp
|
|
562
|
+
break
|
|
563
|
+
if connector_detail:
|
|
564
|
+
break
|
|
565
|
+
|
|
566
|
+
if not connector_detail:
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
if not connector_detail.get("isEnabled", False):
|
|
570
|
+
return None
|
|
571
|
+
if connector_detail.get("targetPlatform") != "CrossPlatform":
|
|
572
|
+
return None
|
|
573
|
+
if connector_detail.get("isExperimental", True):
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
config = json.loads(connector_detail.get("configuration", "{}"))
|
|
578
|
+
except json.JSONDecodeError:
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
activity_type = config.get("activityType", "")
|
|
582
|
+
|
|
583
|
+
# Operation name — the human-readable verb (Create/Retrieve/etc.) lives
|
|
584
|
+
# on connectorMethodInfo when present; fall back to the model.context
|
|
585
|
+
# entry, then to the connectorDetail.configuration blob (Generic CRUD
|
|
586
|
+
# actions carry it only there).
|
|
587
|
+
cmi = node.get("connectorMethodInfo", {}) or {}
|
|
588
|
+
operation_name = (
|
|
589
|
+
cmi.get("operation")
|
|
590
|
+
or _context_value(context, "operation")
|
|
591
|
+
or config.get("operation")
|
|
592
|
+
or ""
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# API endpoint path. `connectorMethodInfo.path` is in the un-enriched
|
|
596
|
+
# registry response (no connection-id needed) and matches the
|
|
597
|
+
# `inputs.detail.endpoint` field in Flow nodes — see uipath-maestro-flow
|
|
598
|
+
# connector plugin docs.
|
|
599
|
+
path = cmi.get("path") or ""
|
|
600
|
+
|
|
601
|
+
# Operation/object display name — what the editor uses for "objectDisplayName"
|
|
602
|
+
# in the Flow configuration blob. Lives under `connectorMethodInfo.curated`
|
|
603
|
+
# when present; we fall back to the top-level `display.label`.
|
|
604
|
+
curated = cmi.get("curated") or {}
|
|
605
|
+
object_display_name = curated.get("displayName") or ""
|
|
606
|
+
|
|
607
|
+
# Input fields. The registry describes an operation's inputs in two places
|
|
608
|
+
# and neither is reliably complete: `inputDefinition.fields` can be missing
|
|
609
|
+
# outright, or present but stripped down to name/type/required/description
|
|
610
|
+
# while `connectorMethodInfo.parameters` carries the lookup and the choice
|
|
611
|
+
# list for the very same input. So take the fields when they exist and fill
|
|
612
|
+
# their gaps from the parameters, and build them from the parameters when
|
|
613
|
+
# they do not. Gaps only: a value the node already states wins, because
|
|
614
|
+
# that is the node's own view of itself.
|
|
615
|
+
param_meta = _parameter_metadata(cmi)
|
|
616
|
+
declared_fields = node.get("inputDefinition", {}).get("fields") or []
|
|
617
|
+
input_fields = []
|
|
618
|
+
if declared_fields:
|
|
619
|
+
for declared in declared_fields:
|
|
620
|
+
merged = dict(declared)
|
|
621
|
+
for key, value in param_meta.get(merged.get("name"), {}).items():
|
|
622
|
+
merged.setdefault(key, value)
|
|
623
|
+
input_fields.append(merged)
|
|
624
|
+
else:
|
|
625
|
+
for p in cmi.get("parameters", []) or []:
|
|
626
|
+
f = {
|
|
627
|
+
"name": p.get("name", ""),
|
|
628
|
+
"type": p.get("dataType", "string"),
|
|
629
|
+
"required": p.get("required", False),
|
|
630
|
+
"description": p.get("description", ""),
|
|
631
|
+
}
|
|
632
|
+
f.update(param_meta.get(f["name"], {}))
|
|
633
|
+
input_fields.append(f)
|
|
634
|
+
|
|
635
|
+
output_fields = (
|
|
636
|
+
node.get("outputDefinition", {}).get("fields")
|
|
637
|
+
or node.get("outputResponseDefinition", {}).get("fields")
|
|
638
|
+
or []
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
display = node.get("display", {}) or {}
|
|
642
|
+
|
|
643
|
+
entry = {
|
|
644
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
645
|
+
"nodeType": node_type,
|
|
646
|
+
"version": config.get("version", "") or node.get("version", ""),
|
|
647
|
+
"category": node.get("category", ""),
|
|
648
|
+
"tags": node.get("tags", []),
|
|
649
|
+
"connector": {
|
|
650
|
+
"key": config.get("connectorKey", ""),
|
|
651
|
+
},
|
|
652
|
+
"operation": {
|
|
653
|
+
"name": operation_name,
|
|
654
|
+
"objectName": config.get("objectName", ""),
|
|
655
|
+
"objectDisplayName": object_display_name,
|
|
656
|
+
"httpMethod": config.get("httpMethod", ""),
|
|
657
|
+
"path": path,
|
|
658
|
+
"subType": config.get("subType", ""),
|
|
659
|
+
"supportsStreaming": config.get("supportsStreaming", False),
|
|
660
|
+
"activityType": activity_type,
|
|
661
|
+
# A connector EVENT's operation is not `operation.name` — that is
|
|
662
|
+
# the object's CRUD verb (`Create` on outlook `email-received`).
|
|
663
|
+
# The event's own operation is the `model.context` entry the
|
|
664
|
+
# platform stamps (`EMAIL_RECEIVED`), and it is the key every
|
|
665
|
+
# trigger API takes: `is triggers objects/describe <key>
|
|
666
|
+
# <OPERATION>`. The SDK compares it too (`identifyEvent` reads
|
|
667
|
+
# `operation.eventOperation`), so carry it on the entry rather
|
|
668
|
+
# than making every reader dig through the sidecar's context.
|
|
669
|
+
**({"eventOperation": _context_value(context, "operation") or ""}
|
|
670
|
+
if str(activity_type).endswith("Trigger") else {}),
|
|
671
|
+
**({"eventMode": node.get("eventMode") or ""}
|
|
672
|
+
if str(activity_type).endswith("Trigger") and node.get("eventMode")
|
|
673
|
+
else {}),
|
|
674
|
+
},
|
|
675
|
+
"display": {
|
|
676
|
+
"label": display.get("label", "") or node.get("displayName", ""),
|
|
677
|
+
"description": display.get("description", "") or node.get("description", ""),
|
|
678
|
+
"icon": display.get("icon", ""),
|
|
679
|
+
"iconBackground": display.get("iconBackground", ""),
|
|
680
|
+
"iconBackgroundDark": display.get("iconBackgroundDark", ""),
|
|
681
|
+
},
|
|
682
|
+
"runtime": {
|
|
683
|
+
"bpmnType": model.get("type", ""),
|
|
684
|
+
"serviceType": model.get("serviceType", ""),
|
|
685
|
+
"activityConfigurationVersion": _context_value(context, "activityConfigurationVersion") or "",
|
|
686
|
+
"requiresConnection": _has_context(context, "connection"),
|
|
687
|
+
"requiresFolderKey": _has_context(context, "folderKey"),
|
|
688
|
+
# Connector discovery metadata replaces the old empty-schema
|
|
689
|
+
# heuristic later in merge_connector_record().
|
|
690
|
+
"requiresConnectionForSchema": False,
|
|
691
|
+
},
|
|
692
|
+
"inputSchema": {"fields": input_fields},
|
|
693
|
+
"outputSchema": {"fields": output_fields},
|
|
694
|
+
}
|
|
695
|
+
# Some operations resolve their real field set from the VALUES of a few
|
|
696
|
+
# parent fields, so what is above is only what holds before any are known.
|
|
697
|
+
# Record what a caller has to do about it; without this the catalog reads
|
|
698
|
+
# as though Jira create-issue takes two inputs.
|
|
699
|
+
action = schema_action(cmi, input_fields)
|
|
700
|
+
if action:
|
|
701
|
+
entry["operation"]["schemaAction"] = action
|
|
702
|
+
# Curated dispatch-style override: a few ops the IS CLI exposes as a POST whose
|
|
703
|
+
# inputs are query-string params with a required (empty) body — a dispatcher must
|
|
704
|
+
# dispatch them as `run create <obj> --query <inputs> --body '{}'`, not the
|
|
705
|
+
# default create→--body. (Data Service `QueryEntityRecords_V3`:
|
|
706
|
+
# POST /v3/QueryEntityRecords/query, params entityScope/tenantEntityName/
|
|
707
|
+
# queryExpression in --query.)
|
|
708
|
+
_dispatch_style = CURATED_DISPATCH_STYLE.get(
|
|
709
|
+
(config.get("connectorKey", ""), config.get("objectName", ""))
|
|
710
|
+
)
|
|
711
|
+
if _dispatch_style:
|
|
712
|
+
entry["operation"]["dispatchStyle"] = _dispatch_style
|
|
713
|
+
# Curated per-parameter grammar tags (see CURATED_PARAM_GRAMMARS): keeps a
|
|
714
|
+
# clean library regen carrying the same validation contract the committed
|
|
715
|
+
# curated defs ship, so the dispatch-input gate survives restaging.
|
|
716
|
+
_param_grammars = CURATED_PARAM_GRAMMARS.get(
|
|
717
|
+
(config.get("connectorKey", ""), config.get("objectName", ""))
|
|
718
|
+
)
|
|
719
|
+
if _param_grammars:
|
|
720
|
+
entry["operation"]["paramGrammars"] = _param_grammars
|
|
721
|
+
return entry
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# ---------------------------------------------------------------------------
|
|
725
|
+
# `is connectors metadata` enrichment (connector-level, not action-level)
|
|
726
|
+
# ---------------------------------------------------------------------------
|
|
727
|
+
#
|
|
728
|
+
# Each connector record carries the display name and the four discovery
|
|
729
|
+
# capabilities from element metadata. Phase 1 deliberately applies the derived
|
|
730
|
+
# schema flag to every activity for the connector.
|
|
731
|
+
|
|
732
|
+
def is_connector_get(connector_key, cache_dir):
|
|
733
|
+
"""Cached `uip is connectors metadata <key>`.
|
|
734
|
+
|
|
735
|
+
Returns the parsed first-element dict, or None on any failure.
|
|
736
|
+
"""
|
|
737
|
+
# Do not reuse legacy `connectors get` cache files: that output omitted the
|
|
738
|
+
# discovery capabilities and would silently mark every connector static.
|
|
739
|
+
safe = _safe_filename(connector_key, "_connector_metadata")
|
|
740
|
+
cache_file = os.path.join(cache_dir, f"{safe}.json")
|
|
741
|
+
if os.path.exists(cache_file):
|
|
742
|
+
with open(cache_file) as f:
|
|
743
|
+
d = json.load(f)
|
|
744
|
+
data = d.get("Data") or []
|
|
745
|
+
return data[0] if isinstance(data, list) and data else None
|
|
746
|
+
result = subprocess.run(
|
|
747
|
+
["uip", "is", "connectors", "metadata", connector_key,
|
|
748
|
+
"--output", "json"],
|
|
749
|
+
capture_output=True, text=True
|
|
750
|
+
)
|
|
751
|
+
try:
|
|
752
|
+
payload = json.loads(result.stdout)
|
|
753
|
+
except json.JSONDecodeError:
|
|
754
|
+
return None
|
|
755
|
+
if payload.get("Result") != "Success":
|
|
756
|
+
with open(cache_file, "w") as f:
|
|
757
|
+
json.dump({"Result": "Failure", "_cached_failure": True}, f)
|
|
758
|
+
return None
|
|
759
|
+
with open(cache_file, "w") as f:
|
|
760
|
+
json.dump(payload, f)
|
|
761
|
+
data = payload.get("Data") or []
|
|
762
|
+
return data[0] if isinstance(data, list) and data else None
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def fetch_connector_records(entries, cache_dir, concurrency):
|
|
766
|
+
"""Fetch one `is connectors metadata` per unique connector key in entries.
|
|
767
|
+
|
|
768
|
+
Returns a dict of connector_key → connector record (or None).
|
|
769
|
+
"""
|
|
770
|
+
keys = sorted({e["connector"]["key"] for e in entries if e["connector"]["key"]})
|
|
771
|
+
return fetch_connector_records_for_keys(keys, cache_dir, concurrency)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def fetch_connector_records_for_keys(keys, cache_dir, concurrency):
|
|
775
|
+
"""Fetch `is connectors metadata` for an iterable of connector keys."""
|
|
776
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
777
|
+
keys = sorted({key for key in keys if key})
|
|
778
|
+
print(f" Looking up {len(keys)} connector records...")
|
|
779
|
+
out = {}
|
|
780
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
781
|
+
futures = {pool.submit(is_connector_get, k, cache_dir): k for k in keys}
|
|
782
|
+
done = 0
|
|
783
|
+
for fut in as_completed(futures):
|
|
784
|
+
k = futures[fut]
|
|
785
|
+
done += 1
|
|
786
|
+
try:
|
|
787
|
+
out[k] = fut.result()
|
|
788
|
+
except Exception:
|
|
789
|
+
out[k] = None
|
|
790
|
+
if done % 50 == 0 or done == len(keys):
|
|
791
|
+
print(f" Fetched {done}/{len(keys)} connector records")
|
|
792
|
+
return out
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def action_connector_key(node_type):
|
|
796
|
+
"""Return the connector key for an ACTION node type, or None.
|
|
797
|
+
|
|
798
|
+
Action node types are `uipath.connector.<key>.<operation>`. The connector
|
|
799
|
+
event forms carry an extra segment
|
|
800
|
+
(`uipath.connector.{trigger,event}.<key>.<event>`) and are answered by
|
|
801
|
+
`event_connector_key` instead.
|
|
802
|
+
"""
|
|
803
|
+
prefix = "uipath.connector."
|
|
804
|
+
if not node_type.startswith(prefix):
|
|
805
|
+
return None
|
|
806
|
+
remainder = node_type[len(prefix):]
|
|
807
|
+
if remainder.startswith(("trigger.", "event.")):
|
|
808
|
+
return None
|
|
809
|
+
connector_key, separator, _operation = remainder.partition(".")
|
|
810
|
+
return connector_key if separator and connector_key else None
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def event_connector_key(node_type):
|
|
814
|
+
"""Return the connector key for a WAIT-form event node type, or None.
|
|
815
|
+
|
|
816
|
+
The platform ships every connector event twice — the mid-flow wait
|
|
817
|
+
`uipath.connector.event.<key>.<event>` and the start trigger
|
|
818
|
+
`uipath.connector.trigger.<key>.<event>` — and only the WAIT form is
|
|
819
|
+
answered here, because only it can be generated. `registry get` REFUSES
|
|
820
|
+
the start form without a live `--connection-id` (a build would then be
|
|
821
|
+
gated on owning a connection for every trigger-supporting connector),
|
|
822
|
+
while the wait form answers offline and carries the same identity and
|
|
823
|
+
payload schema. The SDK derives the start form from its wait sibling when
|
|
824
|
+
it resolves one (`deriveTriggerForm`), so the single generated entry
|
|
825
|
+
serves both node types.
|
|
826
|
+
"""
|
|
827
|
+
prefix = "uipath.connector.event."
|
|
828
|
+
if not node_type.startswith(prefix):
|
|
829
|
+
return None
|
|
830
|
+
connector_key, separator, _event = node_type[len(prefix):].partition(".")
|
|
831
|
+
return connector_key if separator and connector_key else None
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def scoped_connector_key(node_type):
|
|
835
|
+
"""The connector a node type belongs to, for SCOPE filtering.
|
|
836
|
+
|
|
837
|
+
A scope names CONNECTORS, so a scoped build has to be a subset of the full
|
|
838
|
+
one — same connectors, same node families. Keying scope on the action form
|
|
839
|
+
alone made it something else: every connector event silently vanished,
|
|
840
|
+
because `action_connector_key` answers None for them and None matches no
|
|
841
|
+
connector. Measured on the tier1 run of 2026-09-04: 507 action node types
|
|
842
|
+
kept and all 125 event node types dropped, so a scoped library disagreed
|
|
843
|
+
with the full library (448 events) about which events exist at all.
|
|
844
|
+
"""
|
|
845
|
+
return action_connector_key(node_type) or event_connector_key(node_type)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
FIRST_PARTY_PREFIX = "uipath-"
|
|
849
|
+
MOCK_MARKER = "mock"
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def is_first_party_connector(key):
|
|
853
|
+
"""Does this connector key belong in the PUBLISHED catalog?
|
|
854
|
+
|
|
855
|
+
The registry answers with everything the tenant can see, which on a build
|
|
856
|
+
tenant is not the same set an author should be offered:
|
|
857
|
+
|
|
858
|
+
* `custom-<org>-<name>` are connectors somebody built on that tenant. They
|
|
859
|
+
exist nowhere else, so shipping them advertises operations that resolve
|
|
860
|
+
for no other user — measured on the 2026-09 build: 9 keys, 168 entries,
|
|
861
|
+
including `custom-codereval-nulldisplayrepro` and
|
|
862
|
+
`custom-codereval-customconnectortest`, which are bug repros.
|
|
863
|
+
* `uipath-mock-*` are the fixtures the eval harness dispatches against.
|
|
864
|
+
Three keys, 470 entries — the single largest of them, `uipath-mock-jira`
|
|
865
|
+
at 282, is bigger than most real connectors, so an agent searching for
|
|
866
|
+
"jira" meets the mock before the product.
|
|
867
|
+
|
|
868
|
+
Together 12 of 161 keys and 638 of 8,035 entries (7.9%). An explicit
|
|
869
|
+
`--connectors <key>` still builds either kind: naming one is a statement
|
|
870
|
+
that you want it, and the eval harness does.
|
|
871
|
+
"""
|
|
872
|
+
lowered = str(key or "").lower()
|
|
873
|
+
return lowered.startswith(FIRST_PARTY_PREFIX) and MOCK_MARKER not in lowered
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def catalog_connector_key(node_type):
|
|
877
|
+
"""The connector a node type belongs to, in ANY of its three forms.
|
|
878
|
+
|
|
879
|
+
`scoped_connector_key` answers the two forms a build can GENERATE — the
|
|
880
|
+
action and the WAIT event — and deliberately not the START trigger, which
|
|
881
|
+
`registry get` refuses without a live connection. Membership of a connector
|
|
882
|
+
is a different question from generability: `uipath.connector.trigger.<key>
|
|
883
|
+
.<event>` plainly belongs to `<key>`, and a catalog filter reading it as
|
|
884
|
+
"not a connector node" would keep a tenant-local connector's trigger while
|
|
885
|
+
dropping its own action and wait siblings — the same half-dropped catalog
|
|
886
|
+
`scoped_connector_key` was written to prevent, one form over.
|
|
887
|
+
"""
|
|
888
|
+
key = scoped_connector_key(node_type)
|
|
889
|
+
if key:
|
|
890
|
+
return key
|
|
891
|
+
prefix = "uipath.connector.trigger."
|
|
892
|
+
if not str(node_type or "").startswith(prefix):
|
|
893
|
+
return None
|
|
894
|
+
candidate, _, event = node_type[len(prefix):].partition(".")
|
|
895
|
+
return candidate if candidate and event else None
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def select_published_connectors(node_types):
|
|
899
|
+
"""`(kept node types, dropped connector keys)` for a FULL build.
|
|
900
|
+
|
|
901
|
+
A node type with no connector key at all is not a connector node, so the
|
|
902
|
+
catalog rule has nothing to say about it and it is kept — dropping those
|
|
903
|
+
would be this filter deleting core nodes. Splitting this out of `main` is
|
|
904
|
+
what makes the selection testable without a registry.
|
|
905
|
+
"""
|
|
906
|
+
dropped = sorted({
|
|
907
|
+
key for key in (catalog_connector_key(nt) for nt in node_types)
|
|
908
|
+
if key and not is_first_party_connector(key)
|
|
909
|
+
})
|
|
910
|
+
if not dropped:
|
|
911
|
+
return list(node_types), []
|
|
912
|
+
kept = [
|
|
913
|
+
nt for nt in node_types
|
|
914
|
+
if is_first_party_connector(catalog_connector_key(nt))
|
|
915
|
+
or not catalog_connector_key(nt)
|
|
916
|
+
]
|
|
917
|
+
return kept, dropped
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def connector_tier(record):
|
|
921
|
+
"""Read the connector tier across CLI response casing variants."""
|
|
922
|
+
if not isinstance(record, dict):
|
|
923
|
+
return None
|
|
924
|
+
value = record.get("Tier")
|
|
925
|
+
if value is None:
|
|
926
|
+
value = record.get("tier")
|
|
927
|
+
return str(value).strip() if value is not None else None
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def normalized_connector_tier(record):
|
|
931
|
+
"""Normalize current and legacy connector tier spellings."""
|
|
932
|
+
value = connector_tier(record)
|
|
933
|
+
if value is None:
|
|
934
|
+
return None
|
|
935
|
+
normalized = re.sub(r"[^a-z0-9]", "", value.lower())
|
|
936
|
+
if normalized in {"1", "t1", "tier1"}:
|
|
937
|
+
return "tier1"
|
|
938
|
+
if normalized in {"2", "t2", "tier2"}:
|
|
939
|
+
return "tier2"
|
|
940
|
+
return None
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def filter_node_types_by_tier(node_types, connector_records, tier):
|
|
944
|
+
"""Keep the node types whose connector metadata declares `tier`.
|
|
945
|
+
|
|
946
|
+
Actions and connector events both, per `scoped_connector_key` — a tier is
|
|
947
|
+
a set of connectors, and dropping a connector's events while keeping its
|
|
948
|
+
actions ships a library that contradicts the full build.
|
|
949
|
+
"""
|
|
950
|
+
if tier not in {"tier1", "tier2"}:
|
|
951
|
+
raise ValueError(f"unsupported connector tier: {tier}")
|
|
952
|
+
matching_keys = {
|
|
953
|
+
key for key, record in connector_records.items()
|
|
954
|
+
if normalized_connector_tier(record) == tier
|
|
955
|
+
}
|
|
956
|
+
return [
|
|
957
|
+
node_type for node_type in node_types
|
|
958
|
+
if scoped_connector_key(node_type) in matching_keys
|
|
959
|
+
], matching_keys
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def merge_connector_record(entry, record):
|
|
963
|
+
"""Stamp connector discovery metadata and its Phase 1 derived flag."""
|
|
964
|
+
if not record:
|
|
965
|
+
return entry
|
|
966
|
+
name = record.get("Name")
|
|
967
|
+
if name:
|
|
968
|
+
entry["connector"]["name"] = name
|
|
969
|
+
tier = connector_tier(record)
|
|
970
|
+
if tier is not None:
|
|
971
|
+
entry["connector"]["tier"] = tier
|
|
972
|
+
discovery = {
|
|
973
|
+
"type": record.get("DiscoveryType") or "",
|
|
974
|
+
"hasObjectDiscovery": _as_bool(record.get("HasObjectDiscovery")),
|
|
975
|
+
"hasFieldDiscovery": _as_bool(record.get("HasFieldDiscovery")),
|
|
976
|
+
"hasCustomObjectDiscovery": _as_bool(
|
|
977
|
+
record.get("HasCustomObjectDiscovery")
|
|
978
|
+
),
|
|
979
|
+
"hasCustomFieldDiscovery": _as_bool(
|
|
980
|
+
record.get("HasCustomFieldDiscovery")
|
|
981
|
+
),
|
|
982
|
+
}
|
|
983
|
+
entry["connector"]["discovery"] = discovery
|
|
984
|
+
entry["runtime"]["requiresConnectionForSchema"] = any(
|
|
985
|
+
value for key, value in discovery.items() if key != "type"
|
|
986
|
+
)
|
|
987
|
+
return entry
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _as_bool(value):
|
|
991
|
+
"""Accept the boolean JSON contract and older Yes/No cache rows."""
|
|
992
|
+
if isinstance(value, bool):
|
|
993
|
+
return value
|
|
994
|
+
if isinstance(value, str):
|
|
995
|
+
return value.strip().lower() in {"true", "yes", "1"}
|
|
996
|
+
return False
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
# ---------------------------------------------------------------------------
|
|
1000
|
+
# `is resources describe` enrichment
|
|
1001
|
+
# ---------------------------------------------------------------------------
|
|
1002
|
+
#
|
|
1003
|
+
# `flow registry get` (without --connection-id) returns a sparse view: many
|
|
1004
|
+
# connectors come back with empty inputDefinition/outputDefinition and no
|
|
1005
|
+
# connectorMethodInfo. `is resources describe <connector> <object>
|
|
1006
|
+
# --operation <op>` returns the same data plus path placeholders, parameter
|
|
1007
|
+
# split (path vs query), and full request/response field schemas — and
|
|
1008
|
+
# crucially does so without a connection ID for ALL schema-static connectors.
|
|
1009
|
+
#
|
|
1010
|
+
# Schema-DYNAMIC connectors (Salesforce SFDC and similar) return empty
|
|
1011
|
+
# requestFields/responseFields without a connection-id. We detect this and
|
|
1012
|
+
# tag the entry so downstream tooling knows to consult a per-flow sidecar.
|
|
1013
|
+
|
|
1014
|
+
def _safe_filename(*parts):
|
|
1015
|
+
"""Sanitize parts of a filename so they survive on any FS."""
|
|
1016
|
+
s = "__".join(parts)
|
|
1017
|
+
return re.sub(r"[^a-zA-Z0-9_.@\-]+", "_", s)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _schema_cache_scope():
|
|
1021
|
+
"""Keep static and per-connection schema responses from sharing a cache."""
|
|
1022
|
+
return _CONNECTION_ID or "_static"
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _pick(d, *keys):
|
|
1026
|
+
"""Return the first non-None value among d[k] for k in keys.
|
|
1027
|
+
|
|
1028
|
+
The `uip is resources describe` CLI switched key casing from camelCase
|
|
1029
|
+
to PascalCase at some point (e.g. `availableOperations` →
|
|
1030
|
+
`AvailableOperations`, `requestFields` → `RequestFields`). Use this in
|
|
1031
|
+
every IS-payload read so we keep working under either CLI version.
|
|
1032
|
+
"""
|
|
1033
|
+
if not isinstance(d, dict):
|
|
1034
|
+
return None
|
|
1035
|
+
for k in keys:
|
|
1036
|
+
v = d.get(k)
|
|
1037
|
+
if v is not None:
|
|
1038
|
+
return v
|
|
1039
|
+
return None
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _to_lower_camel(s):
|
|
1043
|
+
"""Map a PascalCase key to camelCase (first character lowered)."""
|
|
1044
|
+
if not isinstance(s, str) or not s:
|
|
1045
|
+
return s
|
|
1046
|
+
if s[0].isupper():
|
|
1047
|
+
return s[0].lower() + s[1:]
|
|
1048
|
+
return s
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _normalize_field_keys(value):
|
|
1052
|
+
"""Recursively lower-camel-case every dict key in `value`.
|
|
1053
|
+
|
|
1054
|
+
The IS CLI started returning PascalCase keys for parameters, fields,
|
|
1055
|
+
and references; the existing library uses camelCase, so we normalize
|
|
1056
|
+
on read to keep the on-disk schema stable across CLI versions.
|
|
1057
|
+
"""
|
|
1058
|
+
if isinstance(value, dict):
|
|
1059
|
+
return {
|
|
1060
|
+
_to_lower_camel(k): _normalize_field_keys(v)
|
|
1061
|
+
for k, v in value.items()
|
|
1062
|
+
}
|
|
1063
|
+
if isinstance(value, list):
|
|
1064
|
+
return [_normalize_field_keys(v) for v in value]
|
|
1065
|
+
return value
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def is_describe_operation(connector_key, object_name, operation, cache_dir):
|
|
1069
|
+
"""Cached `uip is resources describe <connector> <object> --operation <op>`.
|
|
1070
|
+
|
|
1071
|
+
Returns the parsed Data dict on success, or None on any failure (the
|
|
1072
|
+
connector/object/operation combo doesn't exist, network error, etc.).
|
|
1073
|
+
"""
|
|
1074
|
+
parts = [_schema_cache_scope(), connector_key, object_name, operation]
|
|
1075
|
+
field_scope = _field_scope()
|
|
1076
|
+
if field_scope:
|
|
1077
|
+
parts.append(field_scope)
|
|
1078
|
+
safe = _safe_filename(*parts)
|
|
1079
|
+
cache_file = os.path.join(cache_dir, f"{safe}.json")
|
|
1080
|
+
if os.path.exists(cache_file):
|
|
1081
|
+
with open(cache_file) as f:
|
|
1082
|
+
cached = json.load(f)
|
|
1083
|
+
if cached.get("_cached_failure"):
|
|
1084
|
+
_note_failed_field_describe(
|
|
1085
|
+
connector_key, object_name, operation, cached.get("Message")
|
|
1086
|
+
)
|
|
1087
|
+
return cached.get("Data")
|
|
1088
|
+
|
|
1089
|
+
result = subprocess.run(
|
|
1090
|
+
["uip", "is", "resources", "describe", connector_key, object_name,
|
|
1091
|
+
"--operation", operation, "--output", "json"]
|
|
1092
|
+
+ _conn_args() + _field_args(),
|
|
1093
|
+
capture_output=True, text=True
|
|
1094
|
+
)
|
|
1095
|
+
try:
|
|
1096
|
+
payload = json.loads(result.stdout)
|
|
1097
|
+
except json.JSONDecodeError:
|
|
1098
|
+
return None
|
|
1099
|
+
if payload.get("Result") != "Success":
|
|
1100
|
+
_note_failed_field_describe(
|
|
1101
|
+
connector_key, object_name, operation, payload.get("Message")
|
|
1102
|
+
)
|
|
1103
|
+
# Cache the failure too, with a sentinel, so retries are skipped. Keep
|
|
1104
|
+
# the message: it is the actionable half of a parent-field rejection.
|
|
1105
|
+
with open(cache_file, "w") as f:
|
|
1106
|
+
json.dump({
|
|
1107
|
+
"Result": "Failure",
|
|
1108
|
+
"_cached_failure": True,
|
|
1109
|
+
"Message": payload.get("Message"),
|
|
1110
|
+
}, f)
|
|
1111
|
+
return None
|
|
1112
|
+
with open(cache_file, "w") as f:
|
|
1113
|
+
json.dump(payload, f)
|
|
1114
|
+
return payload.get("Data")
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def is_describe_object(connector_key, object_name, cache_dir):
|
|
1118
|
+
"""Cached `uip is resources describe <connector> <object>` (no --operation).
|
|
1119
|
+
|
|
1120
|
+
Returns the parsed Data dict on success (with `AvailableOperations`),
|
|
1121
|
+
or None on failure.
|
|
1122
|
+
"""
|
|
1123
|
+
safe = _safe_filename(
|
|
1124
|
+
_schema_cache_scope(), connector_key, object_name, "_operations"
|
|
1125
|
+
)
|
|
1126
|
+
cache_file = os.path.join(cache_dir, f"{safe}.json")
|
|
1127
|
+
if os.path.exists(cache_file):
|
|
1128
|
+
with open(cache_file) as f:
|
|
1129
|
+
return json.load(f).get("Data")
|
|
1130
|
+
result = subprocess.run(
|
|
1131
|
+
["uip", "is", "resources", "describe", connector_key, object_name,
|
|
1132
|
+
"--output", "json"] + _conn_args(),
|
|
1133
|
+
capture_output=True, text=True
|
|
1134
|
+
)
|
|
1135
|
+
try:
|
|
1136
|
+
payload = json.loads(result.stdout)
|
|
1137
|
+
except json.JSONDecodeError:
|
|
1138
|
+
return None
|
|
1139
|
+
if payload.get("Result") != "Success":
|
|
1140
|
+
with open(cache_file, "w") as f:
|
|
1141
|
+
json.dump({"Result": "Failure", "_cached_failure": True}, f)
|
|
1142
|
+
return None
|
|
1143
|
+
with open(cache_file, "w") as f:
|
|
1144
|
+
json.dump(payload, f)
|
|
1145
|
+
return payload.get("Data")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def is_resources_list(connector_key, cache_dir, operation=None, refresh=False):
|
|
1149
|
+
"""Cached `uip is resources list <connector>` — returns the object catalog.
|
|
1150
|
+
|
|
1151
|
+
Each entry has `Name`, `DisplayName`, `Path`, `Type`, `SubType`.
|
|
1152
|
+
Returns the list on success (possibly empty), or None on failure.
|
|
1153
|
+
|
|
1154
|
+
`refresh` bypasses BOTH caches — this module's file cache and the CLI's own.
|
|
1155
|
+
`uip is resources list` answers from a local cache that never invalidates
|
|
1156
|
+
itself, so an object the tenant gained after that cache was written reads as
|
|
1157
|
+
nonexistent. Measured on uipath-salesforce-sfdc: 18 objects from the stale
|
|
1158
|
+
cache, 864 with `--refresh`, and `InvoiceShare` only in the second. Refreshing
|
|
1159
|
+
on every call would cost a round trip per connector, so callers ask for it
|
|
1160
|
+
when a lookup misses (see `_list_generic_objects`).
|
|
1161
|
+
"""
|
|
1162
|
+
safe = _safe_filename(
|
|
1163
|
+
_schema_cache_scope(), connector_key, operation or "_all_operations",
|
|
1164
|
+
"_resources_list"
|
|
1165
|
+
)
|
|
1166
|
+
cache_file = os.path.join(cache_dir, f"{safe}.json")
|
|
1167
|
+
if not refresh and os.path.exists(cache_file):
|
|
1168
|
+
with open(cache_file) as f:
|
|
1169
|
+
d = json.load(f)
|
|
1170
|
+
return d.get("Data") if isinstance(d.get("Data"), list) else None
|
|
1171
|
+
command = ["uip", "is", "resources", "list", connector_key]
|
|
1172
|
+
if operation:
|
|
1173
|
+
command.extend(["--operation", operation])
|
|
1174
|
+
if refresh:
|
|
1175
|
+
command.append("--refresh")
|
|
1176
|
+
command.extend(["--output", "json"])
|
|
1177
|
+
result = subprocess.run(
|
|
1178
|
+
command + _conn_args(),
|
|
1179
|
+
capture_output=True, text=True
|
|
1180
|
+
)
|
|
1181
|
+
try:
|
|
1182
|
+
payload = json.loads(result.stdout)
|
|
1183
|
+
except json.JSONDecodeError:
|
|
1184
|
+
return None
|
|
1185
|
+
if payload.get("Result") != "Success":
|
|
1186
|
+
with open(cache_file, "w") as f:
|
|
1187
|
+
json.dump({"Result": "Failure", "_cached_failure": True}, f)
|
|
1188
|
+
return None
|
|
1189
|
+
with open(cache_file, "w") as f:
|
|
1190
|
+
json.dump(payload, f)
|
|
1191
|
+
return payload.get("Data") if isinstance(payload.get("Data"), list) else None
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
# Map from registry httpMethod (e.g. "GETBYID") to a guess at the
|
|
1195
|
+
# `is resources` operation name. The `is resources` API uses verb names like
|
|
1196
|
+
# "Retrieve" / "List" / "Create"; the registry uses HTTP-method-shaped strings.
|
|
1197
|
+
HTTP_METHOD_TO_OPERATION = {
|
|
1198
|
+
"GET": "List",
|
|
1199
|
+
"GETBYID": "Retrieve",
|
|
1200
|
+
"POST": "Create",
|
|
1201
|
+
"PATCH": "Update",
|
|
1202
|
+
"PUT": "Replace",
|
|
1203
|
+
"DELETE": "Delete",
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def discover_operation_name(entry, is_cache_dir):
|
|
1208
|
+
"""Find the `is resources` operation name for an entry whose
|
|
1209
|
+
canonical `operation.name` is empty.
|
|
1210
|
+
|
|
1211
|
+
Strategy: list available operations on the object, then match by the
|
|
1212
|
+
httpMethod we already extracted from the registry.
|
|
1213
|
+
"""
|
|
1214
|
+
connector_key = entry["connector"]["key"]
|
|
1215
|
+
object_name = entry["operation"]["objectName"]
|
|
1216
|
+
http_method = entry["operation"]["httpMethod"]
|
|
1217
|
+
if not connector_key or not object_name:
|
|
1218
|
+
return None
|
|
1219
|
+
obj_data = is_describe_object(connector_key, object_name, is_cache_dir)
|
|
1220
|
+
if not obj_data:
|
|
1221
|
+
return None
|
|
1222
|
+
for op in _pick(obj_data, "AvailableOperations", "availableOperations") or []:
|
|
1223
|
+
if _pick(op, "Method", "method") == http_method:
|
|
1224
|
+
return _pick(op, "Name", "name")
|
|
1225
|
+
return None
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def enrich_entry(entry, is_cache_dir):
|
|
1229
|
+
"""Augment a canonical entry with `is resources describe` data.
|
|
1230
|
+
|
|
1231
|
+
Returns the (mutated) entry. On any failure (object not found, no
|
|
1232
|
+
operation match, etc.) returns the entry unchanged so the pipeline
|
|
1233
|
+
degrades gracefully.
|
|
1234
|
+
"""
|
|
1235
|
+
connector_key = entry["connector"]["key"]
|
|
1236
|
+
object_name = entry["operation"]["objectName"]
|
|
1237
|
+
op_name = entry["operation"]["name"]
|
|
1238
|
+
|
|
1239
|
+
# Fallback: no operation name from the registry — discover via httpMethod.
|
|
1240
|
+
if not op_name:
|
|
1241
|
+
op_name = discover_operation_name(entry, is_cache_dir) or \
|
|
1242
|
+
HTTP_METHOD_TO_OPERATION.get(entry["operation"]["httpMethod"])
|
|
1243
|
+
if not op_name:
|
|
1244
|
+
return entry # nothing to do
|
|
1245
|
+
|
|
1246
|
+
data = is_describe_operation(connector_key, object_name, op_name, is_cache_dir)
|
|
1247
|
+
# The registry's verb and the IS CLI's can DISAGREE for the same operation,
|
|
1248
|
+
# and the disagreement used to end enrichment silently — the entry kept its
|
|
1249
|
+
# registry `path`, gained no `pathTemplate`, and carried NO `parameters` at
|
|
1250
|
+
# all, which is an operation nothing can dispatch. Measured on Data Service
|
|
1251
|
+
# `QueryEntityRecordsCurated`: the registry says
|
|
1252
|
+
# `connectorMethodInfo.operation: "List"`, and `describe … --operation List`
|
|
1253
|
+
# answers *"Operation 'List' not found. Available: Create"*. So when the
|
|
1254
|
+
# registry's name does not resolve, ask the OBJECT which operations it
|
|
1255
|
+
# actually has and retry with the one whose HTTP method matches
|
|
1256
|
+
# (`discover_operation_name` — already written for the empty-name case, and
|
|
1257
|
+
# the same question one condition over).
|
|
1258
|
+
#
|
|
1259
|
+
# `operation.name` deliberately keeps the REGISTRY's verb even when the
|
|
1260
|
+
# describe answered under another: it maps to a CRUD verb (List →
|
|
1261
|
+
# `list`) and gen-tests synthesises the offline fixture from it, so
|
|
1262
|
+
# rewriting it here would change how a correct flow replays. What the
|
|
1263
|
+
# discovered name is for is reaching the schema.
|
|
1264
|
+
if not data:
|
|
1265
|
+
alt = discover_operation_name(entry, is_cache_dir)
|
|
1266
|
+
if alt and alt != op_name:
|
|
1267
|
+
data = is_describe_operation(connector_key, object_name, alt, is_cache_dir)
|
|
1268
|
+
if not data:
|
|
1269
|
+
return entry # connector might be a stub or recently removed
|
|
1270
|
+
|
|
1271
|
+
op_data = _pick(data, "Operation", "operation") or {}
|
|
1272
|
+
|
|
1273
|
+
# If we'd discovered the name via fallback, persist it now.
|
|
1274
|
+
op_resolved_name = _pick(op_data, "Name", "name")
|
|
1275
|
+
if not entry["operation"]["name"] and op_resolved_name:
|
|
1276
|
+
entry["operation"]["name"] = op_resolved_name
|
|
1277
|
+
|
|
1278
|
+
# Path with placeholders (supersedes the simple `path` we extracted).
|
|
1279
|
+
op_path = _pick(op_data, "Path", "path")
|
|
1280
|
+
if op_path:
|
|
1281
|
+
entry["operation"]["pathTemplate"] = op_path
|
|
1282
|
+
# Object/operation display name from `is resources` data.
|
|
1283
|
+
if not entry["operation"].get("objectDisplayName"):
|
|
1284
|
+
display_name = _pick(data, "DisplayName", "displayName")
|
|
1285
|
+
if display_name:
|
|
1286
|
+
entry["operation"]["objectDisplayName"] = display_name
|
|
1287
|
+
op_curated = _pick(op_data, "Curated", "curated")
|
|
1288
|
+
if op_curated and not entry["display"].get("operationLabel"):
|
|
1289
|
+
entry["display"]["operationLabel"] = op_curated
|
|
1290
|
+
|
|
1291
|
+
# Parameters (path / query) — keep the raw shape (camelCase keys);
|
|
1292
|
+
# downstream tooling filters by `type` (path | query).
|
|
1293
|
+
params = _pick(data, "Parameters", "parameters") or []
|
|
1294
|
+
if params:
|
|
1295
|
+
entry["operation"]["parameters"] = _normalize_field_keys(params)
|
|
1296
|
+
|
|
1297
|
+
# Do not infer schema discovery from empty field lists: delete and file
|
|
1298
|
+
# operations legitimately expose neither. Connector metadata owns that flag.
|
|
1299
|
+
request_fields = _pick(data, "RequestFields", "requestFields") or []
|
|
1300
|
+
response_fields = _pick(data, "ResponseFields", "responseFields") or []
|
|
1301
|
+
|
|
1302
|
+
if request_fields:
|
|
1303
|
+
# Replace/augment the registry-derived inputSchema.
|
|
1304
|
+
entry["inputSchema"] = {"fields": _normalize_field_keys(request_fields)}
|
|
1305
|
+
if response_fields:
|
|
1306
|
+
entry["outputSchema"] = {"fields": _normalize_field_keys(response_fields)}
|
|
1307
|
+
|
|
1308
|
+
return entry
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def enrich_all(entries, is_cache_dir, concurrency):
|
|
1312
|
+
"""Run enrich_entry on every canonical entry in parallel."""
|
|
1313
|
+
os.makedirs(is_cache_dir, exist_ok=True)
|
|
1314
|
+
enriched = [None] * len(entries)
|
|
1315
|
+
pathTemplate_added = 0
|
|
1316
|
+
schema_added = 0
|
|
1317
|
+
|
|
1318
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
1319
|
+
futures = {pool.submit(enrich_entry, e, is_cache_dir): i
|
|
1320
|
+
for i, e in enumerate(entries)}
|
|
1321
|
+
done = 0
|
|
1322
|
+
for fut in as_completed(futures):
|
|
1323
|
+
i = futures[fut]
|
|
1324
|
+
done += 1
|
|
1325
|
+
try:
|
|
1326
|
+
enriched[i] = fut.result()
|
|
1327
|
+
except Exception as exc:
|
|
1328
|
+
# Non-fatal — keep the un-enriched entry.
|
|
1329
|
+
enriched[i] = entries[i]
|
|
1330
|
+
print(f" WARN: enrichment failed for {entries[i]['nodeType']}: {exc}",
|
|
1331
|
+
file=sys.stderr)
|
|
1332
|
+
if done % 100 == 0 or done == len(entries):
|
|
1333
|
+
print(f" Enriched {done}/{len(entries)}")
|
|
1334
|
+
|
|
1335
|
+
for e in enriched:
|
|
1336
|
+
if e.get("operation", {}).get("pathTemplate"):
|
|
1337
|
+
pathTemplate_added += 1
|
|
1338
|
+
if e.get("inputSchema", {}).get("fields") or e.get("outputSchema", {}).get("fields"):
|
|
1339
|
+
schema_added += 1
|
|
1340
|
+
|
|
1341
|
+
print(f" pathTemplate added on {pathTemplate_added}/{len(entries)}")
|
|
1342
|
+
print(f" schema added/augmented on {schema_added}/{len(entries)}")
|
|
1343
|
+
return enriched
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
# ---------------------------------------------------------------------------
|
|
1347
|
+
# Generic-action expansion
|
|
1348
|
+
# ---------------------------------------------------------------------------
|
|
1349
|
+
#
|
|
1350
|
+
# A Generic registry action (e.g. `uipath.connector.<key>.insert-record`) is
|
|
1351
|
+
# an untyped runtime dispatcher: the object (Contact, Issue, …) is picked at
|
|
1352
|
+
# design time, not baked into the action. We materialize one library entry
|
|
1353
|
+
# per `(connector, object, operation)` the IS resources catalog advertises,
|
|
1354
|
+
# using the matching Generic action (by HTTP method) as a template.
|
|
1355
|
+
#
|
|
1356
|
+
# Layout (per the design decision):
|
|
1357
|
+
# library/<connector>/generic/<object>/<op>@<ver>.json
|
|
1358
|
+
#
|
|
1359
|
+
# The expanded entries keep the *source* Generic nodeType so an emitter can produce
|
|
1360
|
+
# a valid Flow node by re-stamping `operation.objectName` onto the same
|
|
1361
|
+
# nodeType the editor uses.
|
|
1362
|
+
|
|
1363
|
+
# Registry CRUD verbs are the recognised Generic templates. HTTP escape
|
|
1364
|
+
# hatches (`*-http-request`, objectName usually `httpRequest` but
|
|
1365
|
+
# sometimes null) are kept as-is, not expanded — there's no object catalog
|
|
1366
|
+
# to iterate.
|
|
1367
|
+
_HTTP_PASSTHROUGH_OBJECT_NAMES = {"httpRequest", "HttpRequest", "http_request"}
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _is_http_passthrough(entry):
|
|
1371
|
+
if entry["operation"].get("objectName") in _HTTP_PASSTHROUGH_OBJECT_NAMES:
|
|
1372
|
+
return True
|
|
1373
|
+
# Some connectors leave objectName null on the http-request action;
|
|
1374
|
+
# fall back to recognising the nodeType suffix.
|
|
1375
|
+
action_id = _action_id_from_node_type(entry["nodeType"])
|
|
1376
|
+
return action_id.endswith("http-request")
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _generic_templates_by_op(generic_entries):
|
|
1380
|
+
"""Bucket Generic CRUD entries by IS operation name per connector.
|
|
1381
|
+
|
|
1382
|
+
Generic CRUD actions carry an `operation` verb in their configuration
|
|
1383
|
+
(e.g. `Create`/`Delete`/`Retrieve`/`List`/`Update`/`Replace`) but no
|
|
1384
|
+
`httpMethod`. The IS resources catalog returns the same verb names
|
|
1385
|
+
under `AvailableOperations[].Name`, so bucketing by op name lets us
|
|
1386
|
+
match each (object, op) advertised by IS to the right template.
|
|
1387
|
+
|
|
1388
|
+
Returns {connector_key: {op_name: entry}}.
|
|
1389
|
+
"""
|
|
1390
|
+
out = {}
|
|
1391
|
+
for e in generic_entries:
|
|
1392
|
+
if _is_http_passthrough(e):
|
|
1393
|
+
continue
|
|
1394
|
+
op_name = (e["operation"].get("name") or "").strip()
|
|
1395
|
+
if not op_name:
|
|
1396
|
+
continue
|
|
1397
|
+
key = e["connector"]["key"]
|
|
1398
|
+
if not key:
|
|
1399
|
+
continue
|
|
1400
|
+
out.setdefault(key, {})[op_name] = e
|
|
1401
|
+
return out
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _make_expanded_entry(template, obj, op, op_data):
|
|
1405
|
+
"""Build one expanded entry from a Generic template + (object, op) pair.
|
|
1406
|
+
|
|
1407
|
+
`obj` is one element of `uip is resources list` (PascalCase keys).
|
|
1408
|
+
`op` is one element of `Data.AvailableOperations` from
|
|
1409
|
+
`uip is resources describe <key> <obj>` (PascalCase keys).
|
|
1410
|
+
`op_data` is the `Data` payload of
|
|
1411
|
+
`uip is resources describe <key> <obj> --operation <op_name>`
|
|
1412
|
+
(may be None if the call failed — the entry is still written with
|
|
1413
|
+
an empty schema). The connector-derived discovery flag is copied
|
|
1414
|
+
from the template and is never inferred from field counts here.
|
|
1415
|
+
"""
|
|
1416
|
+
entry = json.loads(json.dumps(template)) # deep copy
|
|
1417
|
+
|
|
1418
|
+
object_name = obj.get("Name") or ""
|
|
1419
|
+
object_display = obj.get("DisplayName") or object_name
|
|
1420
|
+
op_name = _pick(op, "Name", "name") or ""
|
|
1421
|
+
op_method = _pick(op, "Method", "method") or template["operation"].get("httpMethod", "")
|
|
1422
|
+
op_path = _pick(op, "Path", "path") or ""
|
|
1423
|
+
op_description = _pick(op, "Description", "description") or ""
|
|
1424
|
+
|
|
1425
|
+
entry["operation"]["objectName"] = object_name
|
|
1426
|
+
entry["operation"]["objectDisplayName"] = object_display
|
|
1427
|
+
entry["operation"]["name"] = op_name
|
|
1428
|
+
entry["operation"]["httpMethod"] = op_method
|
|
1429
|
+
entry["operation"]["path"] = op_path
|
|
1430
|
+
entry["operation"]["subType"] = obj.get("SubType") or template["operation"].get("subType", "")
|
|
1431
|
+
entry["operation"]["activityType"] = "GenericExpanded"
|
|
1432
|
+
entry["operation"]["sourceNodeType"] = template["nodeType"]
|
|
1433
|
+
|
|
1434
|
+
entry["display"]["label"] = f"{op_name} {object_display}".strip()
|
|
1435
|
+
if op_description:
|
|
1436
|
+
entry["display"]["description"] = op_description
|
|
1437
|
+
|
|
1438
|
+
# Pull schema + path template from the per-operation describe payload.
|
|
1439
|
+
if op_data:
|
|
1440
|
+
op_inner = _pick(op_data, "Operation", "operation") or {}
|
|
1441
|
+
op_inner_path = _pick(op_inner, "Path", "path")
|
|
1442
|
+
if op_inner_path:
|
|
1443
|
+
entry["operation"]["pathTemplate"] = op_inner_path
|
|
1444
|
+
params = _pick(op_data, "Parameters", "parameters") or []
|
|
1445
|
+
if params:
|
|
1446
|
+
entry["operation"]["parameters"] = _normalize_field_keys(params)
|
|
1447
|
+
request_fields = _pick(op_data, "RequestFields", "requestFields") or []
|
|
1448
|
+
response_fields = _pick(op_data, "ResponseFields", "responseFields") or []
|
|
1449
|
+
entry["inputSchema"] = {"fields": _normalize_field_keys(request_fields)}
|
|
1450
|
+
entry["outputSchema"] = {"fields": _normalize_field_keys(response_fields)}
|
|
1451
|
+
else:
|
|
1452
|
+
entry["inputSchema"] = {"fields": []}
|
|
1453
|
+
entry["outputSchema"] = {"fields": []}
|
|
1454
|
+
|
|
1455
|
+
return entry
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def expand_generic_for_connector(
|
|
1459
|
+
connector_key, templates_by_op, is_cache_dir, object_names=None
|
|
1460
|
+
):
|
|
1461
|
+
"""Materialize per-(object, op) entries for one connector.
|
|
1462
|
+
|
|
1463
|
+
Returns a list of expanded entries (possibly empty if the connector has
|
|
1464
|
+
no object catalog or the IS calls fail).
|
|
1465
|
+
"""
|
|
1466
|
+
# A prepared overlay usually carries one Generic template. Let the CLI
|
|
1467
|
+
# filter the connected catalog by that verb before we describe objects.
|
|
1468
|
+
objects = _list_generic_objects(
|
|
1469
|
+
connector_key, templates_by_op, is_cache_dir, object_names=object_names
|
|
1470
|
+
)
|
|
1471
|
+
if not objects:
|
|
1472
|
+
return []
|
|
1473
|
+
|
|
1474
|
+
# Dedupe by (object_name, op_name). Some IS resource catalogs list
|
|
1475
|
+
# the same object twice (e.g. uipath-servicenow-servicenow exposes
|
|
1476
|
+
# `curated_incident_task` twice — a 1-of-2,283 quirk that still
|
|
1477
|
+
# propagates into the index as duplicate library entries with
|
|
1478
|
+
# identical paths). Within one connector, the (object, op) pair
|
|
1479
|
+
# should be unique; skip subsequent duplicates.
|
|
1480
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
1481
|
+
out = []
|
|
1482
|
+
for obj in objects:
|
|
1483
|
+
object_name = obj.get("Name")
|
|
1484
|
+
if not object_name:
|
|
1485
|
+
continue
|
|
1486
|
+
if object_names is not None and object_name not in object_names:
|
|
1487
|
+
continue
|
|
1488
|
+
obj_data = is_describe_object(connector_key, object_name, is_cache_dir)
|
|
1489
|
+
if not obj_data:
|
|
1490
|
+
continue
|
|
1491
|
+
available = _pick(obj_data, "AvailableOperations", "availableOperations") or []
|
|
1492
|
+
for op in available:
|
|
1493
|
+
op_name = _pick(op, "Name", "name")
|
|
1494
|
+
if not op_name:
|
|
1495
|
+
continue
|
|
1496
|
+
pair = (object_name, op_name)
|
|
1497
|
+
if pair in seen_pairs:
|
|
1498
|
+
continue
|
|
1499
|
+
seen_pairs.add(pair)
|
|
1500
|
+
template = templates_by_op.get(op_name)
|
|
1501
|
+
if not template:
|
|
1502
|
+
# No matching Generic action — e.g. the object exposes an
|
|
1503
|
+
# operation whose verb the connector doesn't ship as a
|
|
1504
|
+
# registry node (rare). Skip.
|
|
1505
|
+
continue
|
|
1506
|
+
op_data = is_describe_operation(
|
|
1507
|
+
connector_key, object_name, op_name, is_cache_dir
|
|
1508
|
+
)
|
|
1509
|
+
out.append(_make_expanded_entry(template, obj, op, op_data))
|
|
1510
|
+
return out
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _unique_by_name(objects):
|
|
1514
|
+
"""First occurrence of each `Name`, in order."""
|
|
1515
|
+
unique = {}
|
|
1516
|
+
for obj in objects or []:
|
|
1517
|
+
object_name = obj.get("Name")
|
|
1518
|
+
if object_name and object_name not in unique:
|
|
1519
|
+
unique[object_name] = obj
|
|
1520
|
+
return unique
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _list_generic_objects(
|
|
1524
|
+
connector_key, templates_by_op, is_cache_dir, object_names=None
|
|
1525
|
+
):
|
|
1526
|
+
"""List and deduplicate the objects available to one connector.
|
|
1527
|
+
|
|
1528
|
+
When the caller named specific objects and the catalog does not contain them,
|
|
1529
|
+
retry once with a refresh before believing it. A stale `uip is resources
|
|
1530
|
+
list` cache reports a real object as missing, and the error that follows —
|
|
1531
|
+
"object X was not found or does not support action Y" — sends the reader
|
|
1532
|
+
looking for a connector problem that is not there.
|
|
1533
|
+
"""
|
|
1534
|
+
operation_filter = (
|
|
1535
|
+
next(iter(templates_by_op)) if len(templates_by_op) == 1 else None
|
|
1536
|
+
)
|
|
1537
|
+
objects = is_resources_list(
|
|
1538
|
+
connector_key, is_cache_dir, operation=operation_filter
|
|
1539
|
+
)
|
|
1540
|
+
unique = _unique_by_name(objects)
|
|
1541
|
+
if object_names and not set(object_names).issubset(unique):
|
|
1542
|
+
refreshed = _unique_by_name(is_resources_list(
|
|
1543
|
+
connector_key, is_cache_dir,
|
|
1544
|
+
operation=operation_filter, refresh=True,
|
|
1545
|
+
))
|
|
1546
|
+
# Keep the cached answer when the refresh failed outright, so a network
|
|
1547
|
+
# blip does not turn a working lookup into "not found".
|
|
1548
|
+
if refreshed:
|
|
1549
|
+
unique = refreshed
|
|
1550
|
+
return list(unique.values())
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _order_by_dependency(parents, fields):
|
|
1554
|
+
"""Cascade order for parent fields: a parent another one needs comes first.
|
|
1555
|
+
|
|
1556
|
+
The action's `rules` are not in cascade order — Jira lists
|
|
1557
|
+
`fields.issuetype.id` before `fields.project.key` — but the order is
|
|
1558
|
+
recoverable, because the dependent field's own lookup path carries the
|
|
1559
|
+
other's name (`/project/{fields.project.key}/issuetypes`). `dependsOn` is
|
|
1560
|
+
honoured too where a connector sets it; on the connections measured it was
|
|
1561
|
+
absent even when the path made the dependency plain, so neither source is
|
|
1562
|
+
sufficient alone.
|
|
1563
|
+
"""
|
|
1564
|
+
depends = {}
|
|
1565
|
+
for field in fields:
|
|
1566
|
+
name = field.get("name")
|
|
1567
|
+
if name not in parents:
|
|
1568
|
+
continue
|
|
1569
|
+
reference = field.get("reference") or {}
|
|
1570
|
+
path = str(reference.get("path") or "")
|
|
1571
|
+
explicit = reference.get("dependsOn") or []
|
|
1572
|
+
depends[name] = {
|
|
1573
|
+
other for other in parents
|
|
1574
|
+
if other != name
|
|
1575
|
+
and ("{" + other + "}" in path or other in explicit)
|
|
1576
|
+
}
|
|
1577
|
+
ordered, remaining = [], list(parents)
|
|
1578
|
+
while remaining:
|
|
1579
|
+
free = [p for p in remaining if not depends.get(p, set()) & set(remaining)]
|
|
1580
|
+
if not free:
|
|
1581
|
+
# A cycle should not happen; emit the rest rather than loop.
|
|
1582
|
+
ordered.extend(remaining)
|
|
1583
|
+
break
|
|
1584
|
+
ordered.extend(free)
|
|
1585
|
+
remaining = [p for p in remaining if p not in free]
|
|
1586
|
+
return ordered
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def schema_action(connector_method_info, input_fields):
|
|
1590
|
+
"""The design-time action that resolves an operation's REAL field set.
|
|
1591
|
+
|
|
1592
|
+
Some operations do not have one field list. Jira `create-issue` describes to
|
|
1593
|
+
2 fields with nothing supplied — just the parents that select the schema —
|
|
1594
|
+
and to 32 once a project and an issue type are given; a different issue type
|
|
1595
|
+
in the same project gives a different set again. Salesforce
|
|
1596
|
+
`curated_soqlQuery` is the same mechanism driven by the query text.
|
|
1597
|
+
|
|
1598
|
+
Studio Web runs these automatically as the parent fields change, and
|
|
1599
|
+
`uip is resources describe -f name=value` ports the same call. Neither is
|
|
1600
|
+
discoverable from the catalog today, because the library drops
|
|
1601
|
+
`connectorMethodInfo.design` — so an author reads 2 fields and concludes the
|
|
1602
|
+
operation takes two inputs.
|
|
1603
|
+
|
|
1604
|
+
Recorded here as what a caller has to DO, not as the raw action:
|
|
1605
|
+
|
|
1606
|
+
- `parents` the fields whose values select the schema, in cascade order
|
|
1607
|
+
- `trigger` a rule field carrying no `refFieldValues` is a button, not
|
|
1608
|
+
data: Salesforce's `generateSchema` has to be invoked
|
|
1609
|
+
explicitly, whereas Jira fires on the parents alone
|
|
1610
|
+
- `replaces` which half the result replaces. Jira remaps `input`, so it
|
|
1611
|
+
yields the REQUEST fields; a SOQL query remaps `output`, so it
|
|
1612
|
+
yields the RESPONSE shape. Saying "it gives you the inputs"
|
|
1613
|
+
would be wrong for half of them.
|
|
1614
|
+
"""
|
|
1615
|
+
design = (connector_method_info or {}).get("design") or {}
|
|
1616
|
+
candidates = []
|
|
1617
|
+
for action in design.get("actions") or []:
|
|
1618
|
+
if action.get("actionType") != "api":
|
|
1619
|
+
continue
|
|
1620
|
+
parents, trigger = [], None
|
|
1621
|
+
for rule in action.get("rules") or []:
|
|
1622
|
+
name = rule.get("refFieldName")
|
|
1623
|
+
if not name:
|
|
1624
|
+
continue
|
|
1625
|
+
if rule.get("refFieldValues"):
|
|
1626
|
+
parents.append(name)
|
|
1627
|
+
elif trigger is None:
|
|
1628
|
+
trigger = name
|
|
1629
|
+
if not parents:
|
|
1630
|
+
continue
|
|
1631
|
+
candidates.append((action, parents, trigger))
|
|
1632
|
+
if not candidates:
|
|
1633
|
+
return None
|
|
1634
|
+
|
|
1635
|
+
# A node can declare more than one. SAP OData `list-all-entity-records`
|
|
1636
|
+
# offers GenerateSchemaWithExpand(entity, $expand) alongside
|
|
1637
|
+
# GenerateSchema(entity), and recording the first one read demands
|
|
1638
|
+
# `$expand` — an optional OData feature — before the schema resolves at
|
|
1639
|
+
# all. The smallest parent set is the one every caller can satisfy. Ties
|
|
1640
|
+
# keep declaration order.
|
|
1641
|
+
action, parents, trigger = min(candidates, key=lambda c: len(c[1]))
|
|
1642
|
+
|
|
1643
|
+
remap = (action.get("onSuccess") or {}).get("remapConfiguration") or {}
|
|
1644
|
+
record = {
|
|
1645
|
+
"name": action.get("name") or "",
|
|
1646
|
+
"parents": _order_by_dependency(parents, input_fields),
|
|
1647
|
+
}
|
|
1648
|
+
if trigger:
|
|
1649
|
+
record["trigger"] = trigger
|
|
1650
|
+
# Which half the result replaces. Jira create-issue remaps `input`, so it
|
|
1651
|
+
# yields REQUEST fields; a SOQL query remaps `output`, so it yields the
|
|
1652
|
+
# RESPONSE shape. Several — Drive `apply-file-labels`, SAP BAPI, SAP OData,
|
|
1653
|
+
# every Data Service node — remap BOTH, and calling that "input" hides
|
|
1654
|
+
# that the response shape changes too.
|
|
1655
|
+
replaces_input = remap.get("input") == "replace"
|
|
1656
|
+
replaces_output = remap.get("output") == "replace"
|
|
1657
|
+
if replaces_input and replaces_output:
|
|
1658
|
+
record["replaces"] = "both"
|
|
1659
|
+
elif replaces_input:
|
|
1660
|
+
record["replaces"] = "input"
|
|
1661
|
+
elif replaces_output:
|
|
1662
|
+
record["replaces"] = "output"
|
|
1663
|
+
return record
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
def objects_come_from_the_connection(entry):
|
|
1667
|
+
"""True when this connector's object set is only knowable per connection.
|
|
1668
|
+
|
|
1669
|
+
`hasObjectDiscovery` / `hasCustomObjectDiscovery` say the objects live on
|
|
1670
|
+
the tenant. Expanding those offline bakes whichever objects the generation
|
|
1671
|
+
tenant happened to have — a list that is incomplete by definition (it can
|
|
1672
|
+
never contain a caller's custom objects) and is not necessarily the
|
|
1673
|
+
caller's at all. Worse, the expansions advertise those objects as available
|
|
1674
|
+
nodes while carrying no schema, and collide with the curated nodes that do:
|
|
1675
|
+
Salesforce ships two entries labelled "Create Account", one with 13 fields
|
|
1676
|
+
and one with none.
|
|
1677
|
+
|
|
1678
|
+
So for these connectors we ship one entry per verb instead, marked for
|
|
1679
|
+
discovery, and let the author list the objects against their own
|
|
1680
|
+
connection. Field discovery alone does NOT qualify: the object set is still
|
|
1681
|
+
knowable offline, so expanding is still worth doing even though each
|
|
1682
|
+
object's fields have to be described live.
|
|
1683
|
+
"""
|
|
1684
|
+
discovery = (entry.get("connector") or {}).get("discovery") or {}
|
|
1685
|
+
return bool(
|
|
1686
|
+
discovery.get("hasObjectDiscovery")
|
|
1687
|
+
or discovery.get("hasCustomObjectDiscovery")
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def as_discovery_entry(entry):
|
|
1692
|
+
"""Recast a Generic CRUD template as the discovery entry we ship for it.
|
|
1693
|
+
|
|
1694
|
+
The label matters as much as the flag: the expanded entries were labelled
|
|
1695
|
+
per object ("Update Account"), so nothing in the catalog ever read as
|
|
1696
|
+
"Update Record" and a caller asking for that found nothing.
|
|
1697
|
+
"""
|
|
1698
|
+
entry["operation"]["objectFromDiscovery"] = True
|
|
1699
|
+
entry["operation"]["objectName"] = ""
|
|
1700
|
+
name = entry["operation"].get("name") or ""
|
|
1701
|
+
if name:
|
|
1702
|
+
entry["display"]["label"] = (
|
|
1703
|
+
"List Records" if name == "List" else f"{name} Record"
|
|
1704
|
+
)
|
|
1705
|
+
return entry
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def disambiguate_discovery_labels(entries):
|
|
1709
|
+
"""Keep two discovery entries on one connector from sharing a label.
|
|
1710
|
+
|
|
1711
|
+
`as_discovery_entry` names by verb, so a connector carrying two List-ish
|
|
1712
|
+
Generic nodes ends up with two entries both labelled "List Records":
|
|
1713
|
+
Salesforce ships `list-records` AND `search-records`. The label is what an
|
|
1714
|
+
agent searches on, so identical labels leave the two indistinguishable.
|
|
1715
|
+
Rename only the colliding ones, from their own node type — a connector with
|
|
1716
|
+
a single List node keeps the canonical label.
|
|
1717
|
+
"""
|
|
1718
|
+
groups = {}
|
|
1719
|
+
for entry in entries:
|
|
1720
|
+
if not (entry.get("operation") or {}).get("objectFromDiscovery"):
|
|
1721
|
+
continue
|
|
1722
|
+
key = (
|
|
1723
|
+
(entry.get("connector") or {}).get("key"),
|
|
1724
|
+
(entry.get("display") or {}).get("label"),
|
|
1725
|
+
)
|
|
1726
|
+
groups.setdefault(key, []).append(entry)
|
|
1727
|
+
for items in groups.values():
|
|
1728
|
+
if len(items) < 2:
|
|
1729
|
+
continue
|
|
1730
|
+
for entry in items:
|
|
1731
|
+
slug = (entry.get("nodeType") or "").rsplit(".", 1)[-1]
|
|
1732
|
+
if slug:
|
|
1733
|
+
entry["display"]["label"] = (
|
|
1734
|
+
slug.replace("-", " ").replace("_", " ").title()
|
|
1735
|
+
)
|
|
1736
|
+
return entries
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def triggers_objects_cached(connector_key, operation, cache_dir):
|
|
1740
|
+
"""Cached `uip is triggers objects <key> <OPERATION>` — the EVENT catalog.
|
|
1741
|
+
|
|
1742
|
+
A different catalog from `is resources list`, not a view of it: outlook
|
|
1743
|
+
lists 42 objects for actions and exactly 2 that support the CREATED
|
|
1744
|
+
event. Called WITHOUT a connection on purpose — this pass only runs for
|
|
1745
|
+
connectors whose objects do not come from the connection, and for those
|
|
1746
|
+
the answer is identical either way, so generation stays credential-free.
|
|
1747
|
+
"""
|
|
1748
|
+
safe = _safe_filename(_schema_cache_scope(), connector_key, operation,
|
|
1749
|
+
"_trigger_objects")
|
|
1750
|
+
cache_file = os.path.join(cache_dir, f"{safe}.json")
|
|
1751
|
+
if os.path.exists(cache_file):
|
|
1752
|
+
with open(cache_file) as f:
|
|
1753
|
+
return json.load(f).get("objects") or []
|
|
1754
|
+
try:
|
|
1755
|
+
rows = _events.triggers_objects(connector_key, operation)
|
|
1756
|
+
except _events.EventDiscoveryError as error:
|
|
1757
|
+
print(f" WARN: trigger objects failed for {connector_key} "
|
|
1758
|
+
f"{operation}: {error}", file=sys.stderr)
|
|
1759
|
+
rows = []
|
|
1760
|
+
with open(cache_file, "w") as f:
|
|
1761
|
+
json.dump({"objects": rows}, f)
|
|
1762
|
+
return rows
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def triggers_describe_cached(connector_key, operation, object_name, cache_dir):
|
|
1766
|
+
"""Cached `uip is triggers describe <key> <OPERATION> <object>`.
|
|
1767
|
+
|
|
1768
|
+
The authority for an event's subscription vocabulary; the registry node's
|
|
1769
|
+
copy is a lossy mirror of it.
|
|
1770
|
+
"""
|
|
1771
|
+
safe = _safe_filename(_schema_cache_scope(), connector_key, operation,
|
|
1772
|
+
object_name, "_trigger_describe")
|
|
1773
|
+
cache_file = os.path.join(cache_dir, f"{safe}.json")
|
|
1774
|
+
if os.path.exists(cache_file):
|
|
1775
|
+
with open(cache_file) as f:
|
|
1776
|
+
return json.load(f).get("describe") or {}
|
|
1777
|
+
try:
|
|
1778
|
+
data = _events.triggers_describe(connector_key, operation, object_name)
|
|
1779
|
+
except _events.EventDiscoveryError as error:
|
|
1780
|
+
print(f" WARN: trigger describe failed for {connector_key} "
|
|
1781
|
+
f"{operation} {object_name}: {error}", file=sys.stderr)
|
|
1782
|
+
data = {}
|
|
1783
|
+
with open(cache_file, "w") as f:
|
|
1784
|
+
json.dump({"describe": data}, f)
|
|
1785
|
+
return data
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _apply_event_schema(entry, describe, obj):
|
|
1789
|
+
"""Put an event's OWN schema on the entry, replacing the node manifest's.
|
|
1790
|
+
|
|
1791
|
+
A connector event's `inputDefinition` in the registry manifest is not the
|
|
1792
|
+
subscription's inputs — for outlook `email-received` it is the Message
|
|
1793
|
+
body (`subject`, `body.content`, `toRecipients[*]…`), i.e. the fields you
|
|
1794
|
+
would send to CREATE a message, which an event never sends anywhere. The
|
|
1795
|
+
subscription's actual inputs are its EVENT PARAMETERS, and the platform
|
|
1796
|
+
selects those per field from `fields[*].<field>.events.<OPERATION>`:
|
|
1797
|
+
verified on outlook Message, where describe answers `[parentFolderId]`
|
|
1798
|
+
for EMAIL_RECEIVED (its `events` map names that operation, required) and
|
|
1799
|
+
`[]` for CREATED, while FilterFields stays 15 for both.
|
|
1800
|
+
|
|
1801
|
+
So the entry's `inputSchema` becomes the union rule's answer — describe's
|
|
1802
|
+
`EventParameters` plus the object row's own `Parameters` — and the leftover
|
|
1803
|
+
manifest fields are dropped rather than advertised as inputs.
|
|
1804
|
+
"""
|
|
1805
|
+
fields = _events.union_event_parameters(
|
|
1806
|
+
(describe or {}).get("EventParameters"), (obj or {}).get("Parameters"))
|
|
1807
|
+
entry["inputSchema"] = {"fields": fields}
|
|
1808
|
+
output_fields = _events.camel_fields((describe or {}).get("OutputFields"))
|
|
1809
|
+
if output_fields:
|
|
1810
|
+
entry["outputSchema"] = {"fields": output_fields}
|
|
1811
|
+
entry["eventSchema"] = {
|
|
1812
|
+
"eventParameters": fields,
|
|
1813
|
+
"filterFields": _events.camel_fields((describe or {}).get("FilterFields")),
|
|
1814
|
+
}
|
|
1815
|
+
for src, dst in (("EventMode", "eventMode"),
|
|
1816
|
+
("ByoaConnection", "byoaConnection"),
|
|
1817
|
+
("IsWebhookUrlVisible", "isWebhookUrlVisible")):
|
|
1818
|
+
if (describe or {}).get(src) is not None:
|
|
1819
|
+
entry["eventSchema"][dst] = describe[src]
|
|
1820
|
+
return entry
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _describe_curated_events(entries, is_cache_dir, concurrency):
|
|
1824
|
+
"""Give each CURATED event its own schema, in place.
|
|
1825
|
+
|
|
1826
|
+
Curated events carry a baked object, so there is nothing to expand — but
|
|
1827
|
+
their vocabulary still has to come from `triggers describe` rather than
|
|
1828
|
+
from the node manifest. Called WITHOUT a connection: what comes back is
|
|
1829
|
+
the tenant-agnostic baseline, which is exactly what a baked library should
|
|
1830
|
+
carry. A connector's custom fields stay `prepare`'s job.
|
|
1831
|
+
"""
|
|
1832
|
+
if not entries:
|
|
1833
|
+
return
|
|
1834
|
+
def work(entry):
|
|
1835
|
+
key = entry["connector"]["key"]
|
|
1836
|
+
operation = entry["operation"].get("eventOperation") or ""
|
|
1837
|
+
object_name = entry["operation"].get("objectName") or ""
|
|
1838
|
+
if not operation or not object_name:
|
|
1839
|
+
return entry, None, None
|
|
1840
|
+
rows = triggers_objects_cached(key, operation, is_cache_dir)
|
|
1841
|
+
obj = next((r for r in rows if r.get("Name") == object_name), None)
|
|
1842
|
+
return (entry,
|
|
1843
|
+
triggers_describe_cached(key, operation, object_name, is_cache_dir),
|
|
1844
|
+
obj)
|
|
1845
|
+
|
|
1846
|
+
described = 0
|
|
1847
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
1848
|
+
for entry, describe, obj in pool.map(work, entries):
|
|
1849
|
+
if not describe:
|
|
1850
|
+
continue
|
|
1851
|
+
_apply_event_schema(entry, describe, obj)
|
|
1852
|
+
described += 1
|
|
1853
|
+
print(f" {described}/{len(entries)} curated events described "
|
|
1854
|
+
f"(subscription parameters, filter fields, payload)")
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def _make_expanded_event_entry(template, obj, describe):
|
|
1858
|
+
"""One per-object variant of a generic connector EVENT.
|
|
1859
|
+
|
|
1860
|
+
The variant keeps `activityType: GenericTrigger` deliberately. That value
|
|
1861
|
+
is what tells the SDK an event watches ONE object of the connection — it
|
|
1862
|
+
drives `check`'s EVENT_GENERIC_NO_OBJECT and serialize's refusal to emit
|
|
1863
|
+
an empty `objectName` — so renaming it the way action expansion renames
|
|
1864
|
+
`Generic` to `GenericExpanded` would silently switch both off. The object
|
|
1865
|
+
is carried on `operation.objectName`, which is also the index's variant
|
|
1866
|
+
key, exactly as for an expanded action.
|
|
1867
|
+
"""
|
|
1868
|
+
entry = json.loads(json.dumps(template)) # deep copy
|
|
1869
|
+
object_name = obj.get("Name") or ""
|
|
1870
|
+
object_display = obj.get("DisplayName") or object_name
|
|
1871
|
+
|
|
1872
|
+
entry["operation"]["objectName"] = object_name
|
|
1873
|
+
entry["operation"]["objectDisplayName"] = object_display
|
|
1874
|
+
entry["operation"]["sourceNodeType"] = template["nodeType"]
|
|
1875
|
+
label = (template.get("display") or {}).get("label") or ""
|
|
1876
|
+
entry["display"]["label"] = f"{label} ({object_display})".strip()
|
|
1877
|
+
|
|
1878
|
+
# `eventSchema` is stamped onto the sidecar by `write_library`, which is
|
|
1879
|
+
# where the SDK reads a subscription's vocabulary from.
|
|
1880
|
+
return _apply_event_schema(entry, describe, obj)
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _expand_generic_events(templates, is_cache_dir, concurrency):
|
|
1884
|
+
"""Expand each generic EVENT template into one entry per event object."""
|
|
1885
|
+
if not templates:
|
|
1886
|
+
return []
|
|
1887
|
+
work = []
|
|
1888
|
+
for template in templates:
|
|
1889
|
+
key = template["connector"]["key"]
|
|
1890
|
+
operation = template["operation"].get("eventOperation") or ""
|
|
1891
|
+
if not operation:
|
|
1892
|
+
continue
|
|
1893
|
+
for obj in triggers_objects_cached(key, operation, is_cache_dir):
|
|
1894
|
+
if obj.get("Name"):
|
|
1895
|
+
work.append((template, key, operation, obj))
|
|
1896
|
+
if not work:
|
|
1897
|
+
return []
|
|
1898
|
+
|
|
1899
|
+
expanded = []
|
|
1900
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
1901
|
+
futures = {
|
|
1902
|
+
pool.submit(triggers_describe_cached, key, operation,
|
|
1903
|
+
obj["Name"], is_cache_dir): (template, obj)
|
|
1904
|
+
for template, key, operation, obj in work
|
|
1905
|
+
}
|
|
1906
|
+
for future in as_completed(futures):
|
|
1907
|
+
template, obj = futures[future]
|
|
1908
|
+
expanded.append(
|
|
1909
|
+
_make_expanded_event_entry(template, obj, future.result() or {}))
|
|
1910
|
+
print(f" {len(expanded)} generic EVENT variants expanded from "
|
|
1911
|
+
f"{len(templates)} template(s)")
|
|
1912
|
+
return expanded
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def resolve_connector_events(entries, is_cache_dir, concurrency):
|
|
1916
|
+
"""Give every connector EVENT its real schema; expand the expandable ones.
|
|
1917
|
+
|
|
1918
|
+
Deliberately NOT part of `expand_all_generics`: that function answers a
|
|
1919
|
+
question about Generic CRUD ACTIONS and is skipped by
|
|
1920
|
+
`--skip-generic-expansion`, and an event's schema has nothing to do with
|
|
1921
|
+
that flag. Returns `(entries, expanded)` where `entries` is the input with
|
|
1922
|
+
curated events described in place, and `expanded` holds the per-object
|
|
1923
|
+
variants of the generic events whose objects do not come from the
|
|
1924
|
+
connection.
|
|
1925
|
+
"""
|
|
1926
|
+
os.makedirs(is_cache_dir, exist_ok=True)
|
|
1927
|
+
curated, generic = [], []
|
|
1928
|
+
for e in entries:
|
|
1929
|
+
at = e["operation"].get("activityType", "")
|
|
1930
|
+
if at == "CuratedTrigger":
|
|
1931
|
+
# Not expanded — its object is baked in — but its schema still
|
|
1932
|
+
# comes from the event, not from the node manifest.
|
|
1933
|
+
curated.append(e)
|
|
1934
|
+
elif at == "GenericTrigger":
|
|
1935
|
+
# UNKNOWN is not "static". `objects_come_from_the_connection`
|
|
1936
|
+
# reads two flags that are absent whenever connector enrichment
|
|
1937
|
+
# was skipped or failed, and absent reads as False — so a
|
|
1938
|
+
# connector whose objects DO come from the connection would be
|
|
1939
|
+
# expanded with whatever the connection-free listing returns
|
|
1940
|
+
# (Salesforce: its 4 declared objects, not the tenant's 1,178),
|
|
1941
|
+
# and the library would then advertise those four as the whole
|
|
1942
|
+
# catalog. Expand only where the connector positively says its
|
|
1943
|
+
# objects do not come from the connection.
|
|
1944
|
+
discovery = (e.get("connector") or {}).get("discovery")
|
|
1945
|
+
if not discovery:
|
|
1946
|
+
print(f" WARN: {e['nodeType']} not expanded — no connector "
|
|
1947
|
+
f"discovery metadata, so whether its objects come from "
|
|
1948
|
+
f"the connection is unknown", file=sys.stderr)
|
|
1949
|
+
elif not objects_come_from_the_connection(e):
|
|
1950
|
+
generic.append(e)
|
|
1951
|
+
if not curated and not generic:
|
|
1952
|
+
return entries, []
|
|
1953
|
+
_describe_curated_events(curated, is_cache_dir, concurrency)
|
|
1954
|
+
return entries, _expand_generic_events(generic, is_cache_dir, concurrency)
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
def expand_all_generics(entries, is_cache_dir, concurrency):
|
|
1958
|
+
"""Expand every Generic entry into per-(object, op) entries.
|
|
1959
|
+
|
|
1960
|
+
Returns (curated_and_passthrough_entries, expanded_entries).
|
|
1961
|
+
Curated entries pass through untouched; HTTP-passthrough Generic
|
|
1962
|
+
entries pass through as one-per-connector library entries; CRUD Generic
|
|
1963
|
+
entries are dropped from the output in favor of their expansions.
|
|
1964
|
+
"""
|
|
1965
|
+
os.makedirs(is_cache_dir, exist_ok=True)
|
|
1966
|
+
|
|
1967
|
+
curated_and_passthrough = []
|
|
1968
|
+
generic_crud = []
|
|
1969
|
+
discovery_entries = 0
|
|
1970
|
+
for e in entries:
|
|
1971
|
+
at = e["operation"].get("activityType", "")
|
|
1972
|
+
if at == "Generic":
|
|
1973
|
+
if _is_http_passthrough(e):
|
|
1974
|
+
curated_and_passthrough.append(e)
|
|
1975
|
+
elif objects_come_from_the_connection(e):
|
|
1976
|
+
curated_and_passthrough.append(as_discovery_entry(e))
|
|
1977
|
+
discovery_entries += 1
|
|
1978
|
+
else:
|
|
1979
|
+
generic_crud.append(e)
|
|
1980
|
+
elif at == "GenericTrigger":
|
|
1981
|
+
# A generic connector EVENT poses the same question one level up —
|
|
1982
|
+
# one node type covering every object of the connection — and the
|
|
1983
|
+
# SAME connector flags answer it. Verified against the platform:
|
|
1984
|
+
# outlook (`hasCustomObjectDiscovery: false`) answers `is triggers
|
|
1985
|
+
# objects CREATED` with Message + Calendar whether or not a
|
|
1986
|
+
# connection is passed, so those variants are the same for every
|
|
1987
|
+
# tenant and bake offline; Salesforce (`true`) answers 4 objects
|
|
1988
|
+
# offline and 1,178 against a connection, so it stays a template
|
|
1989
|
+
# the author names an object on and `prepare` materializes.
|
|
1990
|
+
curated_and_passthrough.append(e)
|
|
1991
|
+
else:
|
|
1992
|
+
# Curated or anything else — pass through.
|
|
1993
|
+
curated_and_passthrough.append(e)
|
|
1994
|
+
|
|
1995
|
+
disambiguate_discovery_labels(curated_and_passthrough)
|
|
1996
|
+
|
|
1997
|
+
templates = _generic_templates_by_op(generic_crud)
|
|
1998
|
+
print(f" {len(generic_crud)} Generic CRUD entries across {len(templates)} connectors")
|
|
1999
|
+
print(f" {discovery_entries} kept unexpanded: objects come from the connection")
|
|
2000
|
+
print(f" {sum(1 for e in entries if _is_http_passthrough(e))} HTTP-passthrough entries (kept as-is)")
|
|
2001
|
+
|
|
2002
|
+
expanded = []
|
|
2003
|
+
if not templates:
|
|
2004
|
+
return curated_and_passthrough, expanded
|
|
2005
|
+
|
|
2006
|
+
# Use the same global worker limit for each remote-call phase. Submitting
|
|
2007
|
+
# one future per connector leaves the pool mostly idle when a connector
|
|
2008
|
+
# such as Salesforce or ServiceNow has a much larger object catalog than
|
|
2009
|
+
# the others. Flattening object and operation work prevents that long tail
|
|
2010
|
+
# without creating nested pools that could multiply the concurrency cap.
|
|
2011
|
+
connector_objects = []
|
|
2012
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
2013
|
+
futures = {
|
|
2014
|
+
pool.submit(_list_generic_objects, k, by_method, is_cache_dir): k
|
|
2015
|
+
for k, by_method in templates.items()
|
|
2016
|
+
}
|
|
2017
|
+
done = 0
|
|
2018
|
+
for fut in as_completed(futures):
|
|
2019
|
+
k = futures[fut]
|
|
2020
|
+
done += 1
|
|
2021
|
+
try:
|
|
2022
|
+
connector_objects.extend(
|
|
2023
|
+
(k, obj) for obj in fut.result()
|
|
2024
|
+
)
|
|
2025
|
+
except Exception as exc:
|
|
2026
|
+
print(f" WARN: object listing failed for {k}: {exc}",
|
|
2027
|
+
file=sys.stderr)
|
|
2028
|
+
if done % 25 == 0 or done == len(templates):
|
|
2029
|
+
print(f" Listed objects for {done}/{len(templates)} connectors "
|
|
2030
|
+
f"({len(connector_objects)} objects so far)")
|
|
2031
|
+
|
|
2032
|
+
operation_candidates = []
|
|
2033
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
2034
|
+
futures = {
|
|
2035
|
+
pool.submit(is_describe_object, k, obj["Name"], is_cache_dir):
|
|
2036
|
+
(k, obj)
|
|
2037
|
+
for k, obj in connector_objects
|
|
2038
|
+
}
|
|
2039
|
+
done = 0
|
|
2040
|
+
for fut in as_completed(futures):
|
|
2041
|
+
k, obj = futures[fut]
|
|
2042
|
+
done += 1
|
|
2043
|
+
try:
|
|
2044
|
+
obj_data = fut.result()
|
|
2045
|
+
except Exception as exc:
|
|
2046
|
+
obj_data = None
|
|
2047
|
+
print(f" WARN: object describe failed for {k}/"
|
|
2048
|
+
f"{obj['Name']}: {exc}", file=sys.stderr)
|
|
2049
|
+
if obj_data:
|
|
2050
|
+
available = (
|
|
2051
|
+
_pick(obj_data, "AvailableOperations", "availableOperations")
|
|
2052
|
+
or []
|
|
2053
|
+
)
|
|
2054
|
+
seen_op_names = set()
|
|
2055
|
+
for op in available:
|
|
2056
|
+
op_name = _pick(op, "Name", "name")
|
|
2057
|
+
if not op_name or op_name in seen_op_names:
|
|
2058
|
+
continue
|
|
2059
|
+
seen_op_names.add(op_name)
|
|
2060
|
+
template = templates[k].get(op_name) if op_name else None
|
|
2061
|
+
if template:
|
|
2062
|
+
operation_candidates.append((k, obj, op, template))
|
|
2063
|
+
if done % 100 == 0 or done == len(connector_objects):
|
|
2064
|
+
print(f" Described {done}/{len(connector_objects)} objects "
|
|
2065
|
+
f"({len(operation_candidates)} operations so far)")
|
|
2066
|
+
|
|
2067
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
2068
|
+
futures = {
|
|
2069
|
+
pool.submit(
|
|
2070
|
+
is_describe_operation,
|
|
2071
|
+
k,
|
|
2072
|
+
obj["Name"],
|
|
2073
|
+
_pick(op, "Name", "name"),
|
|
2074
|
+
is_cache_dir,
|
|
2075
|
+
): (k, obj, op, template)
|
|
2076
|
+
for k, obj, op, template in operation_candidates
|
|
2077
|
+
}
|
|
2078
|
+
done = 0
|
|
2079
|
+
for fut in as_completed(futures):
|
|
2080
|
+
k, obj, op, template = futures[fut]
|
|
2081
|
+
done += 1
|
|
2082
|
+
try:
|
|
2083
|
+
op_data = fut.result()
|
|
2084
|
+
except Exception as exc:
|
|
2085
|
+
op_data = None
|
|
2086
|
+
print(f" WARN: operation describe failed for {k}/"
|
|
2087
|
+
f"{obj['Name']}/{_pick(op, 'Name', 'name')}: {exc}",
|
|
2088
|
+
file=sys.stderr)
|
|
2089
|
+
expanded.append(_make_expanded_entry(template, obj, op, op_data))
|
|
2090
|
+
if done % 100 == 0 or done == len(operation_candidates):
|
|
2091
|
+
print(f" Expanded {done}/{len(operation_candidates)} "
|
|
2092
|
+
"object operations")
|
|
2093
|
+
|
|
2094
|
+
schema_dynamic = sum(
|
|
2095
|
+
1 for e in expanded
|
|
2096
|
+
if e["runtime"].get("requiresConnectionForSchema")
|
|
2097
|
+
)
|
|
2098
|
+
print(f" expanded {len(expanded)} entries from {len(generic_crud)} Generic CRUD actions")
|
|
2099
|
+
print(f" schema-dynamic among expanded: {schema_dynamic}/{len(expanded)}")
|
|
2100
|
+
return curated_and_passthrough, expanded
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
# ---------------------------------------------------------------------------
|
|
2104
|
+
# Library output
|
|
2105
|
+
# ---------------------------------------------------------------------------
|
|
2106
|
+
|
|
2107
|
+
def _action_id_from_node_type(node_type):
|
|
2108
|
+
"""Extract the action segment from a uipath.connector.<key>.<action> nodeType."""
|
|
2109
|
+
parts = node_type.split(".")
|
|
2110
|
+
return parts[-1] if len(parts) >= 4 else node_type.replace(".", "-")
|
|
2111
|
+
|
|
2112
|
+
|
|
2113
|
+
def _safe_path_segment(s):
|
|
2114
|
+
"""Sanitize a string for use as one path segment."""
|
|
2115
|
+
return re.sub(r"[^a-zA-Z0-9_.\-]+", "_", s) or "_"
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _input_field_count(entry):
|
|
2119
|
+
"""How much of a schema an entry carries — the tie-break when two claim one
|
|
2120
|
+
path, since whichever loses is dropped from the catalogue entirely."""
|
|
2121
|
+
return len(((entry.get("inputSchema") or {}).get("fields")) or [])
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _entry_relpath(entry):
|
|
2125
|
+
"""Where to write `entry` relative to the library root.
|
|
2126
|
+
|
|
2127
|
+
Returns the JSON path (sans `.json`); add `.json` / `.v1def.json` to get
|
|
2128
|
+
the final filenames. Expanded Generic entries land under
|
|
2129
|
+
`<connector>/generic/<object>/<op>@<ver>` so the original curated +
|
|
2130
|
+
HTTP-passthrough files stay at the connector top level.
|
|
2131
|
+
"""
|
|
2132
|
+
connector_key = entry["connector"]["key"] or "unknown"
|
|
2133
|
+
version = entry["version"] or "0.0.0"
|
|
2134
|
+
if entry["operation"].get("activityType") == "GenericExpanded":
|
|
2135
|
+
obj = _safe_path_segment(entry["operation"]["objectName"] or "object")
|
|
2136
|
+
op = _safe_path_segment((entry["operation"]["name"] or "op").lower())
|
|
2137
|
+
return os.path.join(connector_key, "generic", obj, f"{op}@{version}")
|
|
2138
|
+
action_id = _action_id_from_node_type(entry["nodeType"])
|
|
2139
|
+
# An expanded generic EVENT keeps its template's nodeType (that value is
|
|
2140
|
+
# load-bearing for the SDK — see `_make_expanded_event_entry`), so the
|
|
2141
|
+
# action id alone is the SAME path for every object and the variants would
|
|
2142
|
+
# overwrite each other down to one. The object is what separates them,
|
|
2143
|
+
# exactly as it does for an expanded action.
|
|
2144
|
+
if (entry["operation"].get("activityType") == "GenericTrigger"
|
|
2145
|
+
and entry["operation"].get("objectName")):
|
|
2146
|
+
obj = _safe_path_segment(entry["operation"]["objectName"])
|
|
2147
|
+
return os.path.join(connector_key, "events", obj, f"{action_id}@{version}")
|
|
2148
|
+
return os.path.join(connector_key, f"{action_id}@{version}")
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
def _build_v1_definition(node_data):
|
|
2152
|
+
"""Project a registry `Data.Node` into the shape the Flow .flow file
|
|
2153
|
+
expects under definitions[]. The registry response is a near-superset:
|
|
2154
|
+
drop fields the Flow def doesn't carry (`connectorMethodInfo`,
|
|
2155
|
+
`outputResponseDefinition`) and add three standard fields the editor
|
|
2156
|
+
stamps on every connector definition (`supportsErrorHandling`,
|
|
2157
|
+
`inputDefaults`, `debug`).
|
|
2158
|
+
"""
|
|
2159
|
+
keep = {
|
|
2160
|
+
k: v for k, v in node_data.items()
|
|
2161
|
+
if k not in ("connectorMethodInfo", "outputResponseDefinition")
|
|
2162
|
+
}
|
|
2163
|
+
keep.setdefault("supportsErrorHandling", True)
|
|
2164
|
+
keep.setdefault("inputDefaults", {})
|
|
2165
|
+
keep.setdefault("debug", {"runtime": "bpmnEngine"})
|
|
2166
|
+
return keep
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def _load_node_data(json_path):
|
|
2170
|
+
"""Pull `Data.Node` from a cached `flow registry get` response."""
|
|
2171
|
+
with open(json_path) as f:
|
|
2172
|
+
d = json.load(f)
|
|
2173
|
+
return d.get("Data", {}).get("Node")
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def write_library(output_dir, entries, v1def_source_paths):
|
|
2177
|
+
"""Write one JSON file per (connector, action, version) plus a sibling
|
|
2178
|
+
`.v1def.json` carrying the Flow definitions[] shape, plus an index.json.
|
|
2179
|
+
|
|
2180
|
+
`v1def_source_paths` maps nodeType → path of the cached registry JSON
|
|
2181
|
+
so we can reach back to the raw `Data.Node` at write time.
|
|
2182
|
+
"""
|
|
2183
|
+
if os.path.exists(output_dir):
|
|
2184
|
+
shutil.rmtree(output_dir)
|
|
2185
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
2186
|
+
|
|
2187
|
+
index = []
|
|
2188
|
+
written = 0
|
|
2189
|
+
claimed = {}
|
|
2190
|
+
|
|
2191
|
+
for entry in entries:
|
|
2192
|
+
connector_key = entry["connector"]["key"] or "unknown"
|
|
2193
|
+
version = entry["version"] or "0.0.0"
|
|
2194
|
+
|
|
2195
|
+
relpath = _entry_relpath(entry) # no extension
|
|
2196
|
+
# Two entries can land on one path, and the second write used to
|
|
2197
|
+
# silently overwrite the first — leaving index.json one row longer than
|
|
2198
|
+
# the directory. In the tier-1 catalogue it happens once, and not
|
|
2199
|
+
# because of a duplicate: CURATED_OP_OVERRIDE renames Outlook365's
|
|
2200
|
+
# multipart `ReplyToEmailV2` to `ReplyToEmail`, which is also a real
|
|
2201
|
+
# object of its own, so the renamed entry (6 fields, still pointing at
|
|
2202
|
+
# `/ReplyToEmailV2`) collides with the genuine one (10 fields).
|
|
2203
|
+
#
|
|
2204
|
+
# One of them has to go. Prefer the entry with more input fields rather
|
|
2205
|
+
# than whichever the iteration reached first: the thinner one loses
|
|
2206
|
+
# less, and the choice does not flip when the object list is reordered.
|
|
2207
|
+
previous = claimed.get(relpath)
|
|
2208
|
+
if previous is not None:
|
|
2209
|
+
if previous == entry:
|
|
2210
|
+
continue
|
|
2211
|
+
if _input_field_count(entry) <= _input_field_count(previous):
|
|
2212
|
+
print(f" WARN: {relpath} claimed by two differing entries; "
|
|
2213
|
+
f"keeping the one with more inputs "
|
|
2214
|
+
f"({_input_field_count(previous)} vs "
|
|
2215
|
+
f"{_input_field_count(entry)})", file=sys.stderr)
|
|
2216
|
+
continue
|
|
2217
|
+
# This one is richer — drop the row already recorded for it.
|
|
2218
|
+
print(f" WARN: {relpath} claimed by two differing entries; "
|
|
2219
|
+
f"replacing the thinner one "
|
|
2220
|
+
f"({_input_field_count(previous)} -> "
|
|
2221
|
+
f"{_input_field_count(entry)})", file=sys.stderr)
|
|
2222
|
+
index[:] = [row for row in index if row["path"] != relpath + ".json"]
|
|
2223
|
+
written -= 1
|
|
2224
|
+
claimed[relpath] = entry
|
|
2225
|
+
|
|
2226
|
+
path = os.path.join(output_dir, relpath + ".json")
|
|
2227
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
2228
|
+
|
|
2229
|
+
with open(path, "w") as f:
|
|
2230
|
+
json.dump(entry, f, indent=2)
|
|
2231
|
+
f.write("\n")
|
|
2232
|
+
written += 1
|
|
2233
|
+
|
|
2234
|
+
# Flow definition sidecar — used when emitting a flow. Expanded
|
|
2235
|
+
# Generic entries reuse the source Generic action's `Data.Node` as
|
|
2236
|
+
# a v1def TEMPLATE; the converter is responsible for stamping the
|
|
2237
|
+
# entry's `operation.objectName` and inputSchema into the Flow node
|
|
2238
|
+
# at conversion time.
|
|
2239
|
+
v1def_lookup_key = (
|
|
2240
|
+
entry["operation"].get("sourceNodeType")
|
|
2241
|
+
if entry["operation"].get("activityType") == "GenericExpanded"
|
|
2242
|
+
else entry["nodeType"]
|
|
2243
|
+
)
|
|
2244
|
+
v1def_src = v1def_source_paths.get(v1def_lookup_key)
|
|
2245
|
+
if v1def_src:
|
|
2246
|
+
try:
|
|
2247
|
+
node_data = _load_node_data(v1def_src)
|
|
2248
|
+
if node_data:
|
|
2249
|
+
v1def = _build_v1_definition(node_data)
|
|
2250
|
+
# A Generic registry node is only a dispatch template. Its
|
|
2251
|
+
# connection/object-resolved body fields live on the
|
|
2252
|
+
# expanded entry, so stamp them into the sidecar consumed by
|
|
2253
|
+
# compile and TypeScript generation.
|
|
2254
|
+
#
|
|
2255
|
+
# A Curated node can also land here with no inputs at all.
|
|
2256
|
+
# When an operation takes only query/path/multipart
|
|
2257
|
+
# parameters, the registry leaves `inputDefinition` empty and
|
|
2258
|
+
# describes those inputs under
|
|
2259
|
+
# `connectorMethodInfo.parameters` — which
|
|
2260
|
+
# `_build_v1_definition` drops. The entry already carries
|
|
2261
|
+
# them, because building `inputSchema` falls back to
|
|
2262
|
+
# `parameters`, so fill the gap from there rather than ship a
|
|
2263
|
+
# definition with no inputs. Only fill when empty: a node
|
|
2264
|
+
# that has real `inputDefinition` fields keeps them.
|
|
2265
|
+
expanded = (
|
|
2266
|
+
entry["operation"].get("activityType") == "GenericExpanded"
|
|
2267
|
+
)
|
|
2268
|
+
resolved_fields = entry.get("inputSchema", {}).get("fields") or []
|
|
2269
|
+
# The entry's fields are the node's own fields plus whatever
|
|
2270
|
+
# the parameters filled in, so they are a superset of what a
|
|
2271
|
+
# verbatim projection would carry — prefer them whenever we
|
|
2272
|
+
# have any. An expanded Generic node stamps even an empty
|
|
2273
|
+
# list, because its template's fields belong to the dispatch
|
|
2274
|
+
# node rather than to this object.
|
|
2275
|
+
if expanded or resolved_fields:
|
|
2276
|
+
v1def.setdefault("inputDefinition", {})["fields"] = (
|
|
2277
|
+
resolved_fields
|
|
2278
|
+
)
|
|
2279
|
+
# A per-object EVENT variant carries its own subscription
|
|
2280
|
+
# vocabulary, and this sidecar is where the SDK reads one
|
|
2281
|
+
# (`Library.eventParameterFields` / `eventFilterFields`).
|
|
2282
|
+
# The template's registry node is the same for every
|
|
2283
|
+
# object, so without this stamp every variant would answer
|
|
2284
|
+
# with the template's sections — which for a generic event
|
|
2285
|
+
# are empty, and `check` would then refuse every filter
|
|
2286
|
+
# field the object actually has.
|
|
2287
|
+
event_schema = entry.get("eventSchema") or {}
|
|
2288
|
+
if event_schema:
|
|
2289
|
+
v1def["eventParameters"] = {
|
|
2290
|
+
"fields": event_schema.get("eventParameters") or []
|
|
2291
|
+
}
|
|
2292
|
+
v1def["filterFields"] = {
|
|
2293
|
+
"fields": event_schema.get("filterFields") or []
|
|
2294
|
+
}
|
|
2295
|
+
for fact in ("eventMode", "byoaConnection",
|
|
2296
|
+
"isWebhookUrlVisible"):
|
|
2297
|
+
if event_schema.get(fact) is not None:
|
|
2298
|
+
v1def[fact] = event_schema[fact]
|
|
2299
|
+
v1def_path = os.path.join(output_dir, relpath + ".v1def.json")
|
|
2300
|
+
with open(v1def_path, "w") as f:
|
|
2301
|
+
json.dump(v1def, f, indent=2)
|
|
2302
|
+
f.write("\n")
|
|
2303
|
+
except Exception as exc:
|
|
2304
|
+
print(f" WARN: v1def write failed for {entry['nodeType']}: {exc}",
|
|
2305
|
+
file=sys.stderr)
|
|
2306
|
+
|
|
2307
|
+
index.append({
|
|
2308
|
+
"nodeType": entry["nodeType"],
|
|
2309
|
+
"version": version,
|
|
2310
|
+
"connectorKey": connector_key,
|
|
2311
|
+
"label": entry["display"]["label"],
|
|
2312
|
+
"activityType": entry["operation"].get("activityType", ""),
|
|
2313
|
+
# `objectName` disambiguates expansions that share a nodeType
|
|
2314
|
+
# (e.g. `insert-record` × N objects). Curated entries usually have
|
|
2315
|
+
# an objectName too; those lookups omit objectName and
|
|
2316
|
+
# match by nodeType alone.
|
|
2317
|
+
"objectName": entry["operation"].get("objectName", "") or "",
|
|
2318
|
+
"operationName": entry["operation"].get("name", "") or "",
|
|
2319
|
+
"path": os.path.relpath(path, output_dir),
|
|
2320
|
+
})
|
|
2321
|
+
# `registry search` reads only this index, so a flag left in the entry
|
|
2322
|
+
# file is invisible to the one consumer that has to branch on it.
|
|
2323
|
+
# Emitted only when set, so every other row stays byte-identical.
|
|
2324
|
+
if entry["operation"].get("objectFromDiscovery"):
|
|
2325
|
+
index[-1]["objectFromDiscovery"] = True
|
|
2326
|
+
|
|
2327
|
+
# Sort index for stable diffs. Many expanded entries share the source
|
|
2328
|
+
# Generic nodeType, so include `path` to break ties.
|
|
2329
|
+
index.sort(key=lambda e: (e["connectorKey"], e["nodeType"], e["version"], e["path"]))
|
|
2330
|
+
|
|
2331
|
+
index_path = os.path.join(output_dir, "index.json")
|
|
2332
|
+
with open(index_path, "w") as f:
|
|
2333
|
+
json.dump({"schemaVersion": SCHEMA_VERSION, "entries": index}, f, indent=2)
|
|
2334
|
+
f.write("\n")
|
|
2335
|
+
|
|
2336
|
+
return written
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
# ---------------------------------------------------------------------------
|
|
2340
|
+
# Main
|
|
2341
|
+
# ---------------------------------------------------------------------------
|
|
2342
|
+
|
|
2343
|
+
def main():
|
|
2344
|
+
args = parse_args()
|
|
2345
|
+
|
|
2346
|
+
print(f"Cache directory: {args.cache_dir}")
|
|
2347
|
+
print(f"Output directory: {args.output_dir}")
|
|
2348
|
+
|
|
2349
|
+
node_types = registry_search(args.cache_dir)
|
|
2350
|
+
print(f"Found {len(node_types)} connector node types")
|
|
2351
|
+
|
|
2352
|
+
connector_records = None
|
|
2353
|
+
|
|
2354
|
+
# Curated subset: keep the requested connectors' action AND connector-event
|
|
2355
|
+
# node types (`scoped_connector_key`). The START form
|
|
2356
|
+
# (`uipath.connector.trigger.<key>.<event>`) stays out — `registry get`
|
|
2357
|
+
# refuses it without a --connection-id, so including it would only buy a
|
|
2358
|
+
# flood of doomed calls, and the SDK derives that form from the wait
|
|
2359
|
+
# sibling this build does materialize.
|
|
2360
|
+
if args.connectors:
|
|
2361
|
+
allow = {c.strip() for c in args.connectors.split(",") if c.strip()}
|
|
2362
|
+
before = len(node_types)
|
|
2363
|
+
node_types = [
|
|
2364
|
+
nt for nt in node_types
|
|
2365
|
+
if scoped_connector_key(nt) in allow
|
|
2366
|
+
]
|
|
2367
|
+
matched = {
|
|
2368
|
+
key for key in allow
|
|
2369
|
+
if any(f".{key}." in f"{nt}." for nt in node_types)
|
|
2370
|
+
}
|
|
2371
|
+
print(f"Curated subset: kept {len(node_types)}/{before} node types for "
|
|
2372
|
+
f"{len(matched)}/{len(allow)} connectors {sorted(matched)}")
|
|
2373
|
+
missing = allow - matched
|
|
2374
|
+
if missing:
|
|
2375
|
+
print(f" WARN: no registry nodes matched these connector keys: "
|
|
2376
|
+
f"{sorted(missing)}", file=sys.stderr)
|
|
2377
|
+
else:
|
|
2378
|
+
# A full build ships the PUBLISHED catalog, not everything the build
|
|
2379
|
+
# tenant happens to see.
|
|
2380
|
+
before = len(node_types)
|
|
2381
|
+
node_types, dropped = select_published_connectors(node_types)
|
|
2382
|
+
if dropped:
|
|
2383
|
+
print(f"Published catalog: kept {len(node_types)}/{before} node "
|
|
2384
|
+
f"types, dropped {len(dropped)} tenant-local connector(s) "
|
|
2385
|
+
f"{dropped}")
|
|
2386
|
+
|
|
2387
|
+
if args.catalog_scope in {"tier1", "tier2"}:
|
|
2388
|
+
requested_tier = args.catalog_scope
|
|
2389
|
+
tier_number = requested_tier[-1]
|
|
2390
|
+
scope_keys = {
|
|
2391
|
+
key for key in (scoped_connector_key(nt) for nt in node_types) if key
|
|
2392
|
+
}
|
|
2393
|
+
print(f"Resolving Tier {tier_number} scope from metadata for "
|
|
2394
|
+
f"{len(scope_keys)} connector keys...")
|
|
2395
|
+
connector_records = fetch_connector_records_for_keys(
|
|
2396
|
+
scope_keys, args.connectors_cache_dir, args.concurrency
|
|
2397
|
+
)
|
|
2398
|
+
missing_metadata = sorted(
|
|
2399
|
+
key for key, record in connector_records.items() if record is None
|
|
2400
|
+
)
|
|
2401
|
+
if missing_metadata:
|
|
2402
|
+
preview = ", ".join(missing_metadata[:10])
|
|
2403
|
+
remainder = (
|
|
2404
|
+
f" (+{len(missing_metadata) - 10} more)"
|
|
2405
|
+
if len(missing_metadata) > 10 else ""
|
|
2406
|
+
)
|
|
2407
|
+
sys.exit(
|
|
2408
|
+
f"Tier {tier_number} filtering requires connector metadata for every "
|
|
2409
|
+
f"candidate connector, but lookup failed for: {preview}{remainder}"
|
|
2410
|
+
)
|
|
2411
|
+
before = len(node_types)
|
|
2412
|
+
node_types, matching_keys = filter_node_types_by_tier(
|
|
2413
|
+
node_types, connector_records, requested_tier
|
|
2414
|
+
)
|
|
2415
|
+
if not matching_keys:
|
|
2416
|
+
sys.exit(
|
|
2417
|
+
f"Tier {tier_number} filtering selected no connectors. Expected "
|
|
2418
|
+
"the Tier field from `uip is connectors metadata` to contain "
|
|
2419
|
+
f"'{tier_number}'."
|
|
2420
|
+
)
|
|
2421
|
+
print(f"Tier {tier_number} scope: kept {len(node_types)}/{before} action "
|
|
2422
|
+
f"node types for {len(matching_keys)} connectors")
|
|
2423
|
+
|
|
2424
|
+
# Side-file: catalog every trigger nodeType the registry search exposed.
|
|
2425
|
+
# Triggers are NOT materialized into the main library because
|
|
2426
|
+
# `flow registry get <trigger>` requires --connection-id; this file is
|
|
2427
|
+
# the closest we get to a connection-less trigger inventory.
|
|
2428
|
+
triggers_output = args.triggers_output or os.path.join(
|
|
2429
|
+
os.path.dirname(os.path.normpath(args.output_dir)) or ".",
|
|
2430
|
+
"registry-triggers.json",
|
|
2431
|
+
)
|
|
2432
|
+
print(f"Writing trigger side-file → {triggers_output}")
|
|
2433
|
+
write_triggers_side_file(args.cache_dir, triggers_output)
|
|
2434
|
+
|
|
2435
|
+
print(f"Fetching connector details (concurrency={args.concurrency})...")
|
|
2436
|
+
cache_files = fetch_all(node_types, args.cache_dir, args.concurrency)
|
|
2437
|
+
print(f"Successfully fetched {len(cache_files)} connectors")
|
|
2438
|
+
|
|
2439
|
+
print("Extracting connector entries (Curated + Generic)...")
|
|
2440
|
+
entries = []
|
|
2441
|
+
v1def_source_paths = {}
|
|
2442
|
+
by_activity = {}
|
|
2443
|
+
for nt, path in sorted(cache_files.items()):
|
|
2444
|
+
try:
|
|
2445
|
+
entry = extract_canonical(path)
|
|
2446
|
+
if entry:
|
|
2447
|
+
entries.append(entry)
|
|
2448
|
+
v1def_source_paths[entry["nodeType"]] = path
|
|
2449
|
+
at = entry["operation"].get("activityType", "") or "(empty)"
|
|
2450
|
+
by_activity[at] = by_activity.get(at, 0) + 1
|
|
2451
|
+
except Exception as e:
|
|
2452
|
+
print(f" WARN: error processing {nt}: {e}", file=sys.stderr)
|
|
2453
|
+
|
|
2454
|
+
print(f"Found {len(entries)} entries total: " +
|
|
2455
|
+
", ".join(f"{k}={v}" for k, v in sorted(by_activity.items())))
|
|
2456
|
+
|
|
2457
|
+
if not args.skip_enrichment:
|
|
2458
|
+
print(f"Enriching via `is resources describe` (concurrency={args.concurrency})...")
|
|
2459
|
+
entries = enrich_all(entries, args.is_cache_dir, args.concurrency)
|
|
2460
|
+
|
|
2461
|
+
if not args.skip_connector_enrichment:
|
|
2462
|
+
print(f"Enriching via `is connectors metadata` "
|
|
2463
|
+
f"(concurrency={args.concurrency})...")
|
|
2464
|
+
if connector_records is None:
|
|
2465
|
+
connector_records = fetch_connector_records(
|
|
2466
|
+
entries, args.connectors_cache_dir, args.concurrency
|
|
2467
|
+
)
|
|
2468
|
+
missing_metadata = sorted(
|
|
2469
|
+
key for key, record in connector_records.items() if record is None
|
|
2470
|
+
)
|
|
2471
|
+
if missing_metadata:
|
|
2472
|
+
preview = ", ".join(missing_metadata[:10])
|
|
2473
|
+
remainder = (
|
|
2474
|
+
f" (+{len(missing_metadata) - 10} more)"
|
|
2475
|
+
if len(missing_metadata) > 10 else ""
|
|
2476
|
+
)
|
|
2477
|
+
sys.exit(
|
|
2478
|
+
"Connector metadata is required to classify schema discovery, "
|
|
2479
|
+
"but `uip is connectors metadata` failed for: "
|
|
2480
|
+
f"{preview}{remainder}. Upgrade the UiPath CLI or rerun after "
|
|
2481
|
+
"checking authentication."
|
|
2482
|
+
)
|
|
2483
|
+
named = 0
|
|
2484
|
+
for e in entries:
|
|
2485
|
+
ck = e["connector"]["key"]
|
|
2486
|
+
rec = connector_records.get(ck)
|
|
2487
|
+
merge_connector_record(e, rec)
|
|
2488
|
+
if e["connector"].get("name"):
|
|
2489
|
+
named += 1
|
|
2490
|
+
schema_dynamic = sum(
|
|
2491
|
+
1 for e in entries
|
|
2492
|
+
if e["runtime"].get("requiresConnectionForSchema")
|
|
2493
|
+
)
|
|
2494
|
+
print(f" Named {named}/{len(entries)} entries from connector records")
|
|
2495
|
+
print(f" schema-dynamic from connector capabilities: "
|
|
2496
|
+
f"{schema_dynamic}/{len(entries)}")
|
|
2497
|
+
|
|
2498
|
+
if not args.skip_generic_expansion:
|
|
2499
|
+
print(f"Expanding Generic CRUD by (object, operation) "
|
|
2500
|
+
f"(concurrency={args.concurrency})...")
|
|
2501
|
+
kept, expanded = expand_all_generics(entries, args.is_cache_dir, args.concurrency)
|
|
2502
|
+
entries = kept + expanded
|
|
2503
|
+
|
|
2504
|
+
print(f"Resolving connector events (concurrency={args.concurrency})...")
|
|
2505
|
+
entries, expanded_events = resolve_connector_events(
|
|
2506
|
+
entries, args.is_cache_dir, args.concurrency)
|
|
2507
|
+
entries = entries + expanded_events
|
|
2508
|
+
|
|
2509
|
+
# Curated op remaps (e.g. Outlook ReplyToEmailV2 → dispatchable ReplyToEmail) — applied
|
|
2510
|
+
# last so they override anything enrichment/expansion produced.
|
|
2511
|
+
entries = apply_curated_overrides(entries)
|
|
2512
|
+
|
|
2513
|
+
print("Writing canonical library...")
|
|
2514
|
+
written = write_library(args.output_dir, entries, v1def_source_paths)
|
|
2515
|
+
print(f"Wrote {written} library entries + index.json")
|
|
2516
|
+
|
|
2517
|
+
if not args.keep_temp:
|
|
2518
|
+
print("Cleaning up caches...")
|
|
2519
|
+
shutil.rmtree(args.cache_dir, ignore_errors=True)
|
|
2520
|
+
shutil.rmtree(args.is_cache_dir, ignore_errors=True)
|
|
2521
|
+
shutil.rmtree(args.connectors_cache_dir, ignore_errors=True)
|
|
2522
|
+
|
|
2523
|
+
print("Done!")
|
|
2524
|
+
|
|
2525
|
+
|
|
2526
|
+
if __name__ == "__main__":
|
|
2527
|
+
main()
|