@syncmatters/connector-sdk 1.0.0 → 1.0.1
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/README.md +13 -6
- package/docs/01-workspace-and-meta-json.md +132 -0
- package/docs/02-lifecycle-and-shape.md +116 -0
- package/docs/03-http-auth-ratelimit.md +188 -0
- package/docs/04-meta-objects-fields.md +162 -0
- package/docs/05-query.md +151 -0
- package/docs/06-upsert-delete.md +194 -0
- package/docs/07-events-webhooks.md +123 -0
- package/docs/08-errors.md +68 -0
- package/docs/09-testing.md +127 -0
- package/docs/10-style-and-pitfalls.md +100 -0
- package/docs/11-composite-keys.md +164 -0
- package/docs/12-example-connector.md +566 -0
- package/docs/13-file-fields.md +123 -0
- package/docs/14-vendor-sdks-and-non-http.md +115 -0
- package/docs/connector-authoring.md +158 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
# @syncmatters/connector-sdk
|
|
2
2
|
|
|
3
|
-
TypeScript type definitions for the **SyncMatters
|
|
3
|
+
TypeScript type definitions **and the connector authoring guide** for the **SyncMatters
|
|
4
|
+
Connector SDK**.
|
|
4
5
|
|
|
5
6
|
This is a **types-only** package: it provides IntelliSense and type checking when developing
|
|
6
7
|
SyncMatters connectors locally (for example in VS Code or Cursor). Connector code executes on the
|
|
7
8
|
SyncMatters platform, which provides the runtime implementation of this SDK.
|
|
8
9
|
|
|
10
|
+
## Authoring guide (humans and AI assistants)
|
|
11
|
+
|
|
12
|
+
The complete guide to building SyncMatters connectors ships inside this package, version-locked
|
|
13
|
+
to the types:
|
|
14
|
+
|
|
15
|
+
- [`docs/connector-authoring.md`](./docs/connector-authoring.md) — start here (cheat sheet +
|
|
16
|
+
index); focused topic files sit alongside it.
|
|
17
|
+
|
|
18
|
+
If you are an AI assistant working in a connector workspace: **read that file before writing
|
|
19
|
+
connector code.** Workspaces created by the `sm` CLI also contain an `AGENTS.md` pointing here.
|
|
20
|
+
|
|
9
21
|
## Usage
|
|
10
22
|
|
|
11
23
|
```js
|
|
@@ -28,11 +40,6 @@ export default class MyConnector {
|
|
|
28
40
|
Add `"checkJs": true` to your `jsconfig.json` to activate the JSDoc type annotations used in
|
|
29
41
|
connector `.mjs` files.
|
|
30
42
|
|
|
31
|
-
## Legacy package name
|
|
32
|
-
|
|
33
|
-
`@ihq/connector-sdk` is the legacy name of this package (IntegrateHQ is now SyncMatters).
|
|
34
|
-
Existing connectors importing the legacy name continue to work on-platform.
|
|
35
|
-
|
|
36
43
|
## Support
|
|
37
44
|
|
|
38
45
|
- <https://syncmatters.com>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Workspace anatomy and the `*.meta.json` sidecar
|
|
2
|
+
|
|
3
|
+
## Two parallel trees
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
files/Connectors/<ConnectorName>/ # source code (what runs on the platform)
|
|
7
|
+
meta/Connectors/<ConnectorName>/ # one *.meta.json sidecar per source file
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- **Entry file**: `<ConnectorName>.mjs`, default-exporting the connector class. Its sidecar has
|
|
11
|
+
`"type": "connector"` and carries the `connector_metadata` block.
|
|
12
|
+
- **Module files**: additional `.mjs` files in the same folder, imported by the entry (or by
|
|
13
|
+
other modules). Sidecar `"type": "module"`.
|
|
14
|
+
- **Resource files**: e.g. a bundled OpenAPI spec (`acme-api.yaml`). Sidecar type `"yaml"` /
|
|
15
|
+
`"json"`. Load big specs lazily (dynamic `import()`), not at module top level.
|
|
16
|
+
- **Test harness files**: `mod Test.mjs` (sidecar type `"connectortest"`) and the generated
|
|
17
|
+
`mod ObjectTestFeatures.mjs` (type `"module"`). The `"mod "` prefix (with a space) is the
|
|
18
|
+
convention. See [09-testing.md](./09-testing.md).
|
|
19
|
+
|
|
20
|
+
Naming: entry and class-per-file modules are PascalCase (`Acme.mjs`); shared-logic modules are
|
|
21
|
+
kebab-case (`acme-v3-query.mjs`). Version-split connectors use a `-v<N>` suffix per version core.
|
|
22
|
+
|
|
23
|
+
## Module wiring: `module_script_file_paths`
|
|
24
|
+
|
|
25
|
+
The platform stages exactly the files listed in the sidecars — an import that isn't listed will
|
|
26
|
+
not exist at runtime.
|
|
27
|
+
|
|
28
|
+
- The **entry** file's sidecar lists **every file of the connector, transitively** (all modules
|
|
29
|
+
and resources).
|
|
30
|
+
- Each **module**'s sidecar lists only **its own direct imports**.
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
// meta/Connectors/Acme/Acme.mjs.meta.json (entry - lists everything)
|
|
34
|
+
"module_script_file_paths": [
|
|
35
|
+
"Connectors/Acme/acme-core.mjs",
|
|
36
|
+
"Connectors/Acme/acme-query.mjs",
|
|
37
|
+
"Connectors/Acme/acme-api.yaml"
|
|
38
|
+
]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Import style in code: static `import` for your main module; dynamic `await import("./acme-v3")`
|
|
42
|
+
for version dispatch so only the selected version's code is loaded:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const version = args.settings.get("connectorVersion") || "1";
|
|
46
|
+
switch (version) {
|
|
47
|
+
case "3": {
|
|
48
|
+
const V3 = await import("./acme-v3");
|
|
49
|
+
this.#core = new V3.AcmeV3();
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, `Connector version '${version}' not recognized`);
|
|
54
|
+
}
|
|
55
|
+
await this.#core.init(args);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Sidecar top-level fields
|
|
59
|
+
|
|
60
|
+
| Field | Values / meaning |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `name` | the file name, e.g. `"Acme.mjs"` |
|
|
63
|
+
| `type` | `"connector"` (entry) · `"module"` · `"connectortest"` · `"json"` · `"yaml"` |
|
|
64
|
+
| `runnable` | `false` for connector files (they are invoked by the platform, not run directly) |
|
|
65
|
+
| `engine` | runtime engine version int; leave as scaffolded unless told otherwise |
|
|
66
|
+
| `resource_size` | `""` or `"medium"` — runtime resource class hint |
|
|
67
|
+
| `cpu_architecture` | normally `""` |
|
|
68
|
+
| `module_script_file_paths` | see above |
|
|
69
|
+
| `npm_packages` | name→version map of npm packages this file needs at runtime, e.g. `{"box-node-sdk": "2.4.0"}`. The `sm` CLI unions these into the workspace `package.json` so IntelliSense sees them too. Prefer the SDK's built-in http/utilities over adding packages. |
|
|
70
|
+
| `connector_metadata` | entry sidecar only — everything below |
|
|
71
|
+
|
|
72
|
+
## `connector_metadata` reference
|
|
73
|
+
|
|
74
|
+
| Field | Meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `categories` | discovery taxonomy, e.g. `["sales"]`, `["file"]`, `["marketing","sales"]` |
|
|
77
|
+
| `agent_mode` | `"never"` = runs in cloud · `"always"` = runs on the customer's on-premise agent · `"optional"` = per-connection (agent used when the connection selects one) |
|
|
78
|
+
| `oauth2_mode` | `"connector"` = one shared OAuth app for every account · `"connection"` = each connection supplies its own client id/secret. Omit for non-OAuth connectors. |
|
|
79
|
+
| `oauth2_provider` | the OAuth provider definition (below) |
|
|
80
|
+
| `webhooks_provider` | `{"mode": "custom.perconnection"}` (each connection registers its own webhook) or `{"mode": "custom.perconnector", "url": ..., "webhook_uid": ...}` (one shared endpoint) |
|
|
81
|
+
| `settings` | the connection-settings form (below) |
|
|
82
|
+
| `icon_url` | connector logo URL |
|
|
83
|
+
| `connector_name`, `type_id`, `application_name`, `application_icon_url` | platform/branding ids — usually left as scaffolded |
|
|
84
|
+
|
|
85
|
+
### `settings`
|
|
86
|
+
|
|
87
|
+
Keyed by setting id; the values arrive in `init()` via `args.settings.get("<id>")`.
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
"settings": {
|
|
91
|
+
"api_key": { "order": "00", "name": "API Key", "type": "string", "control": "singlelinetext", "secret": true },
|
|
92
|
+
"region": { "order": "01", "name": "Region", "type": "string", "control": "select",
|
|
93
|
+
"options": [ { "id": "us", "name": "US", "order": "00" }, { "id": "eu", "name": "EU", "order": "01" } ],
|
|
94
|
+
"default": { "type": "string", "expression": "us" } },
|
|
95
|
+
"connectorVersion": { "order": "02", "name": "Connector Version", "defines_version": true,
|
|
96
|
+
"type": "string", "control": "select",
|
|
97
|
+
"options": [ { "id": "1", "name": "1 (Superseded)", "order": "00" }, { "id": "2", "name": "2 (Recommended)", "order": "01" } ],
|
|
98
|
+
"default": { "type": "string", "expression": "2" } },
|
|
99
|
+
"debug": { "order": "03", "name": "Debug", "advanced": true, "type": "boolean", "control": "singlelinetext" }
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Per-setting fields: `order` (two-digit string, display order), `name`, `type` (`string` |
|
|
104
|
+
`boolean`), `control` (`singlelinetext` | `select`), `secret: true` (masked + encrypted),
|
|
105
|
+
`advanced: true` (collapsed by default), `hidden: true`, `readonly: true`, `placeholder`,
|
|
106
|
+
`options` (for `select`), `default: { "type": "string", "expression": "<value>" }`, and
|
|
107
|
+
`defines_version: true` on the setting whose value picks a version core.
|
|
108
|
+
|
|
109
|
+
Conventions worth copying: a secret `api_key`/`clientId`+`clientSecret` pair for credentials; an
|
|
110
|
+
advanced boolean `debug` setting that gates verbose request/response logging; a
|
|
111
|
+
`connectorVersion` select with `defines_version` when you need breaking revisions to coexist.
|
|
112
|
+
|
|
113
|
+
### `oauth2_provider`
|
|
114
|
+
|
|
115
|
+
Defines the OAuth2 dance the platform performs on the connector's behalf (your code only ever
|
|
116
|
+
sees the resulting token via `args.oauth2Provider` — see
|
|
117
|
+
[03-http-auth-ratelimit.md](./03-http-auth-ratelimit.md)). Fields seen in practice:
|
|
118
|
+
`provider_id`, `grant_type` (`"code"`), `default_scopes`, `client_id`, `has_client_secret`,
|
|
119
|
+
`redirect_uri` (the platform's OAuth callback), `authorize_uri`, `authorize_params`,
|
|
120
|
+
`token_uri`, `refresh_uri`, `introspect_uri`, `authorized_domains`. URIs may template settings,
|
|
121
|
+
e.g. `https://{acme_domain}/oauth2/authorize`. An optional `scope_groups` array drives the
|
|
122
|
+
consent UI: items carry `label` plus `read`/`write`/`full` scope strings and optional
|
|
123
|
+
`read_mandatory: true`.
|
|
124
|
+
|
|
125
|
+
## Do not edit
|
|
126
|
+
|
|
127
|
+
- `.sm/` (CLI state), and the CLI-generated `package.json` / `jsconfig.json` / `AGENTS.md`
|
|
128
|
+
managed block at the workspace root. They are editor tooling, never pushed.
|
|
129
|
+
- Generated `mod ObjectTestFeatures.mjs` files (regenerate instead).
|
|
130
|
+
- System-connector stubs: if a connector's settings include a hidden
|
|
131
|
+
`systemFlag*` boolean, the real implementation is embedded in the platform runtime and the
|
|
132
|
+
`.mjs` stub is deliberately a no-op — don't implement it.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Connector lifecycle and class shape
|
|
2
|
+
|
|
3
|
+
## The class
|
|
4
|
+
|
|
5
|
+
The entry file default-exports a class. Only `test()` and `meta()` are required; every other
|
|
6
|
+
method is optional and its presence unlocks a capability (see the table in
|
|
7
|
+
[connector-authoring.md](./connector-authoring.md)).
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import SDK from "@syncmatters/connector-sdk";
|
|
11
|
+
|
|
12
|
+
export default class Acme {
|
|
13
|
+
/** @type {SDK.Logger | undefined} */ #log;
|
|
14
|
+
|
|
15
|
+
/** @param {SDK.InitArgs} args @returns {Promise<void>} */
|
|
16
|
+
async init(args) {
|
|
17
|
+
SDK.verifyType(this);
|
|
18
|
+
this.#log = args.log;
|
|
19
|
+
// read settings, build http client(s), wire OAuth refresh
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @returns {Promise<SDK.TestResult>} */
|
|
23
|
+
async test() {
|
|
24
|
+
/* ... */
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @returns {Promise<SDK.ConnectorMeta>} */
|
|
28
|
+
async meta() {
|
|
29
|
+
/* ... */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`SDK.verifyType(this)` is a no-op whose only job is to make `checkJs` verify the class against
|
|
35
|
+
the `SDK.Connector` interface — always the first line of `init()`.
|
|
36
|
+
|
|
37
|
+
## `init(args)` — called exactly once per connector instance
|
|
38
|
+
|
|
39
|
+
`SDK.InitArgs` fields:
|
|
40
|
+
|
|
41
|
+
| Field | Type | Use |
|
|
42
|
+
| ----------------------------- | --------------------- | ---------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `id` | `string` | unique id of this connection instance |
|
|
44
|
+
| `name` | `string` | display name of the connection |
|
|
45
|
+
| `identity?` | `string` | system-specific identity if known (e.g. signed-in email) |
|
|
46
|
+
| `mode` | `SDK.ScriptType` | why the instance was created — see below |
|
|
47
|
+
| `log` | `SDK.Logger` | `info(message, props?)` / `error(messageOrError, props?)` to the platform event store |
|
|
48
|
+
| `sourcePath` | `string` | directory holding the connector's staged files |
|
|
49
|
+
| `settings` | `Map<string, any>` | the connection's configured settings (`settings.get("api_key")`) |
|
|
50
|
+
| `oauth2Provider?` | `OAuth2TokenProvider` | present when the connection has a managed OAuth2 token — see [03](./03-http-auth-ratelimit.md) |
|
|
51
|
+
| `webhookUrl?` / `webhookUid?` | `string` | this connection's webhook endpoint / its unique id — see [07](./07-events-webhooks.md) |
|
|
52
|
+
|
|
53
|
+
`init()` should: validate required settings (throw
|
|
54
|
+
`SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, ...)` naming the missing setting), stash
|
|
55
|
+
what you need in `#fields`, build the HTTP client(s), and register the OAuth refresh callback.
|
|
56
|
+
Do **not** make network calls in `init()` unless unavoidable — `test()` is where connectivity
|
|
57
|
+
is proven.
|
|
58
|
+
|
|
59
|
+
### `mode` (`SDK.ScriptType`) — early-out for static modes
|
|
60
|
+
|
|
61
|
+
Values include `"integration"`, `"scriptFile"`, `"parseEvents"`, `"verifyEventIdentity"`,
|
|
62
|
+
`"agentConnection"`, `"syncGroup"`, `"connectorTest"`, `"connectorFeatureGenerate"`,
|
|
63
|
+
`"connectionSemanticTypes"`, `"codeEmbedding"`.
|
|
64
|
+
|
|
65
|
+
Whether event parsing gets a real connection depends on the sidecar's
|
|
66
|
+
`webhooks_provider.mode` (see [07-events-webhooks.md](./07-events-webhooks.md)): with a
|
|
67
|
+
**per-connection** webhook the connector is initialized normally (settings available); with a
|
|
68
|
+
**per-connector** shared endpoint, `parseEvents` runs **statically** — `init()` is called with
|
|
69
|
+
`mode: "parseEvents"` and no settings/token are guaranteed. Shared-endpoint connectors with
|
|
70
|
+
heavy `init()` setup should return early for static modes:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
const isStatic = args.mode === "parseEvents";
|
|
74
|
+
if (isStatic) return; // static parseEvents only needs the http client it builds itself
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## `test()` — prove the credentials
|
|
78
|
+
|
|
79
|
+
Make the cheapest authenticated call the API offers. Catch, don't throw:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
/** @returns {Promise<SDK.TestResult>} */
|
|
83
|
+
async test() {
|
|
84
|
+
try {
|
|
85
|
+
await this.#call({ method: "GET", url: "me" });
|
|
86
|
+
return { success: true };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { success: false, message: err instanceof Error ? err.message : String(err), error: /** @type any */ (err) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`TestResult` = `{ success: boolean; message?: string; error?: CodedError }`.
|
|
94
|
+
|
|
95
|
+
## `meta()` — see [04-meta-objects-fields.md](./04-meta-objects-fields.md)
|
|
96
|
+
|
|
97
|
+
Return `{ identity?, objects: ObjectMeta[], events? }`. `identity` should be a stable string for
|
|
98
|
+
the connected account (e.g. `` `${org.name}|${user.email}` ``) — the platform uses it to detect a
|
|
99
|
+
connection being re-pointed at a different account.
|
|
100
|
+
|
|
101
|
+
## Optional lifecycle methods
|
|
102
|
+
|
|
103
|
+
| Method | Contract |
|
|
104
|
+
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| `syncSessionBegin(session)` / `syncSessionEnd(session)` | bracket a sync-group run; implement only if you need to bootstrap internal caching for the whole run |
|
|
106
|
+
| `dispose(options)` | destroy unmanaged resources at process exit — **best-effort only**, may not run on abnormal exit; never rely on it for correctness |
|
|
107
|
+
| `base()` | return the underlying third-party client instance for diagnostic scripts |
|
|
108
|
+
| `optimalBatchSize(options)` | recommend `{ size, batchType? }` per operation (`"upsert"`, `"idsFilter"`, ...); `batchType` is `"atomic"`, `"concurrent"` or `"splitInsertUpdateAtomic"` |
|
|
109
|
+
|
|
110
|
+
## Multi-version connectors
|
|
111
|
+
|
|
112
|
+
When an API revision would break existing connections, add a `connectorVersion` select setting
|
|
113
|
+
(`defines_version: true`, see [01](./01-workspace-and-meta-json.md)) and split the
|
|
114
|
+
implementation into per-version core modules. The entry class reads the setting in `init()` and
|
|
115
|
+
delegates every SDK method to the selected core (`this.#core.query(options)` etc.), loading the
|
|
116
|
+
core with a dynamic `await import("./acme-v3")` so unselected versions never load.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# HTTP, auth and rate limiting
|
|
2
|
+
|
|
3
|
+
## The one true HTTP idiom
|
|
4
|
+
|
|
5
|
+
Every request goes through an `SDK.utilities.httpClient` built in `init()` with a rate limiter.
|
|
6
|
+
Prefer this over `fetch`, `axios`, or `node:https` — the SDK client is what the platform monitors,
|
|
7
|
+
retries and rate-limits.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
this.#http = SDK.utilities.httpClient({
|
|
11
|
+
limiter: SDK.utilities.rateLimiter({ type: "rolling", count: 10, ms: 1000 }), // 10 req/sec
|
|
12
|
+
maxRetry: 3,
|
|
13
|
+
retryBackoffMs: 2000,
|
|
14
|
+
defaultHeaders: { "Content-Type": "application/json" },
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`rateLimiter` types: `{ type: "rolling", count, ms }` (N requests per window) and
|
|
19
|
+
`{ type: "concurrent", count }` (N in flight). Set them from the API's documented limits.
|
|
20
|
+
|
|
21
|
+
When a window limiter alone is not enough (some APIs also punish burst concurrency), compose
|
|
22
|
+
two limiters:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
const rolling = SDK.utilities.rateLimiter({ type: "rolling", count: 100, ms: 10000 });
|
|
26
|
+
const concurrent = SDK.utilities.rateLimiter({ type: "concurrent", count: 6 });
|
|
27
|
+
const limiter = {
|
|
28
|
+
execute: async (o) => concurrent.execute({ cost: 1, exec: () => rolling.execute(o) }),
|
|
29
|
+
};
|
|
30
|
+
this.#http = SDK.utilities.httpClient({ limiter, maxRetry: 3, retryBackoffMs: 2000 });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Endpoints with their own (stricter) limits get their **own** client — e.g. a search endpoint
|
|
34
|
+
limited to 4 req/s gets a second `httpClient` with a second limiter.
|
|
35
|
+
|
|
36
|
+
### Requests
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const response = await this.#http.execute({
|
|
40
|
+
method: "GET", // GET | POST | PUT | DELETE | PATCH
|
|
41
|
+
url: "https://api.example.com/v1/contacts",
|
|
42
|
+
headers: { Authorization: `Bearer ${this.#token}` },
|
|
43
|
+
body: undefined, // object bodies are JSON-serialized
|
|
44
|
+
// limiterCost?, fullResponse?, fileResponse?
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Non-2xx responses **throw an `HttpError`**: `{ name: "HttpError", code: "HTTP404",
|
|
49
|
+
statusCode, url, responseHeaders, responseBodyString, responseBodyJson, responseBodyBuffer }`.
|
|
50
|
+
Branch on `err.code === "HTTP401"` etc. — see [08-errors.md](./08-errors.md).
|
|
51
|
+
|
|
52
|
+
### The central call wrapper
|
|
53
|
+
|
|
54
|
+
Every mature connector funnels requests through one private method that (a) prepends the base
|
|
55
|
+
URL, (b) attaches auth, (c) debug-logs when the `debug` setting is on:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
/** @param {SDK.HttpRequest} request */
|
|
59
|
+
async #call(request) {
|
|
60
|
+
if (!request.url.startsWith("https://")) request.url = this.#baseUrl + request.url;
|
|
61
|
+
request.headers = { ...request.headers, Authorization: this.#auth };
|
|
62
|
+
try {
|
|
63
|
+
const response = await this.#http.execute(request);
|
|
64
|
+
this.#debug && this.#log.info(`DEBUG: ${request.method} ${request.url}`, { request, response });
|
|
65
|
+
return response;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.#debug && this.#log.info(`DEBUG: ${request.method} ${request.url} failed`, { request, err });
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Retry policy: `canRetry`
|
|
74
|
+
|
|
75
|
+
`canRetry(err, method, url, httpCode)` returns `true`/`false` — or a **number of milliseconds**
|
|
76
|
+
to wait before that retry. The proven policy shape:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const canRetry = (err, method, url, httpCode) => {
|
|
80
|
+
if ([400, 403, 404].includes(httpCode)) return false; // client errors don't heal
|
|
81
|
+
if (method === "GET") return true; // reads are idempotent
|
|
82
|
+
if ([429, 502, 504].includes(httpCode)) return true; // throttle/gateway blips
|
|
83
|
+
const msg = String(err.message || "");
|
|
84
|
+
return msg.includes("socket hang up") || msg.includes("ECONNRESET") || msg.includes("timeout");
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Non-idempotent writes: only retry when you know the API tolerates it. One real-world subtlety:
|
|
89
|
+
writes that reference a _freshly created picklist option_ can fail transiently while the option
|
|
90
|
+
propagates — returning ~10000 (10s backoff) from `canRetry` for that specific error is the
|
|
91
|
+
established fix.
|
|
92
|
+
|
|
93
|
+
## Auth patterns
|
|
94
|
+
|
|
95
|
+
### API key / basic auth (from settings)
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
this.#apiKey = args.settings.get("api_key");
|
|
99
|
+
if (!this.#apiKey)
|
|
100
|
+
throw new SDK.ConnectorError(SDK.ErrorCode.InvalidOptionValue, "Setting 'API Key' is required");
|
|
101
|
+
// basic:
|
|
102
|
+
this.#auth = `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Validate every required setting up front with a clear per-setting message.
|
|
106
|
+
|
|
107
|
+
### OAuth2 (`args.oauth2Provider`)
|
|
108
|
+
|
|
109
|
+
The platform performs the OAuth dance (per the sidecar's `oauth2_provider` definition) and
|
|
110
|
+
hands your code a live token. Three obligations:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// 1. take the current token
|
|
114
|
+
this.#oauth2Provider = args.oauth2Provider;
|
|
115
|
+
this.#token = args.oauth2Provider.token;
|
|
116
|
+
// 2. stay current: the platform refreshes in the background
|
|
117
|
+
args.oauth2Provider.onRefresh((token) => {
|
|
118
|
+
this.#token = token;
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
// 3. refresh-on-401: some APIs invalidate sessions early
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.code === "HTTP401") {
|
|
126
|
+
await this.#oauth2Provider.forceRefresh();
|
|
127
|
+
return this.#callInternal(request); // retry ONCE after refresh
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Also available: `oauth2Provider.scope` (granted scopes — normalize with
|
|
134
|
+
`.replace(/\+/g, " ").split(" ")` and gate optional objects on the scopes actually granted),
|
|
135
|
+
`introspectToken()` (account metadata — useful for `meta().identity`), `expiry`, `authParams`.
|
|
136
|
+
|
|
137
|
+
`oauth2_mode` in the sidecar decides who owns the OAuth app: `"connector"` (one shared app) or
|
|
138
|
+
`"connection"` (each connection brings its own client id/secret) — see
|
|
139
|
+
[01-workspace-and-meta-json.md](./01-workspace-and-meta-json.md).
|
|
140
|
+
|
|
141
|
+
### Self-managed auth (when the managed OAuth2 provider doesn't fit)
|
|
142
|
+
|
|
143
|
+
Some APIs use schemes the platform provider can't do. The fleet's established shapes:
|
|
144
|
+
|
|
145
|
+
- **Client-credentials token endpoint you manage yourself** — a small helper that caches the
|
|
146
|
+
token and refreshes ahead of expiry, called from your request wrapper:
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
async #token() {
|
|
150
|
+
if (this.#expiresAt < Date.now() + 5 * 60 * 1000) { // refresh 5 min early (clock skew)
|
|
151
|
+
const resp = await this.#http.execute({
|
|
152
|
+
method: "POST", url: this.#tokenUrl,
|
|
153
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
154
|
+
body: new URLSearchParams({ grant_type: "client_credentials",
|
|
155
|
+
client_id: this.#clientId, client_secret: this.#clientSecret }).toString(),
|
|
156
|
+
});
|
|
157
|
+
this.#expiresAt = Date.now() + resp.expires_in * 1000;
|
|
158
|
+
this.#auth = `${resp.token_type} ${resp.access_token}`;
|
|
159
|
+
}
|
|
160
|
+
return this.#auth;
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **Request-signing schemes** (OAuth 1.0a/TBA HMAC signing, NTLM, AWS SigV4) — encapsulate the
|
|
165
|
+
signing in its own module (or a vendor package, see
|
|
166
|
+
[14-vendor-sdks-and-non-http.md](./14-vendor-sdks-and-non-http.md)) exposing the same
|
|
167
|
+
`execute(request)` shape as the SDK http client, so the rest of the connector can't tell the
|
|
168
|
+
difference.
|
|
169
|
+
|
|
170
|
+
## OpenAPI-spec-driven APIs
|
|
171
|
+
|
|
172
|
+
If the target publishes an OpenAPI 3 spec, bundle it as a resource file and let the SDK build a
|
|
173
|
+
typed client with auth + rate limiting wired in:
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
const api = await SDK.OpenApi3.createAsync({
|
|
177
|
+
specPathYaml: `${args.sourcePath}/acme-api.yaml`,
|
|
178
|
+
apiKey: { key: this.#apiKey, securityName: "ApiKeyAuth" }, // or oauth2/basicAuth
|
|
179
|
+
rateLimiterOptions: { type: "rolling", count: 10, ms: 1000 },
|
|
180
|
+
serverOverride: this.#baseUrl,
|
|
181
|
+
httpClientOverrides: { maxRetry: 3, defaultHeaders: { Accept: "application/json" } },
|
|
182
|
+
errLog: args.log,
|
|
183
|
+
});
|
|
184
|
+
// api.apis.<Tag>.<operationId>(...) ; api.httpClient / api.rateLimiter also exposed
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Bundled specs are also the proven way to _generate_ `meta()` from the API's schemas at runtime
|
|
188
|
+
— load lazily with `await import()` and map schema properties to `ObjectField[]`.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# `meta()` — objects, fields, flags and keys
|
|
2
|
+
|
|
3
|
+
`meta()` is the contract between your connector and everything the platform does with it. Every
|
|
4
|
+
flag is a promise: set `canQueryByCheckpoint: true` and the platform WILL send checkpoint
|
|
5
|
+
queries. Set nothing you haven't implemented.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
/** @returns {Promise<SDK.ConnectorMeta>} */
|
|
9
|
+
async meta() {
|
|
10
|
+
return { identity: await this.#accountIdentity(), objects: await this.#buildObjects() };
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`ConnectorMeta` = `{ identity?: string; objects: ObjectMeta[]; events?: { identity?, types: EventTypeMeta[] } }`.
|
|
15
|
+
`objects` is an **array**. (`ObjectMetaSummary`/`ObjectMetaDetail` are deprecated — never use.)
|
|
16
|
+
`events` only when you implement webhooks — see [07-events-webhooks.md](./07-events-webhooks.md).
|
|
17
|
+
|
|
18
|
+
## `ObjectMeta` — the canonical shape
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
{
|
|
22
|
+
id: "contacts", // stable object id (what query/upsert receive as options.id)
|
|
23
|
+
name: "Contacts", // display name
|
|
24
|
+
canQueryList: true, // supports a plain "give me everything" paged query
|
|
25
|
+
canQueryByIds: true, // supports queryOptions.idsFilter
|
|
26
|
+
canQueryByCheckpoint: true, // supports differential (changed-since) queries
|
|
27
|
+
canQueryRandoms: true, // random sample support
|
|
28
|
+
matchRules: ["id", "field_value_equals[ci]"], // match rules the object supports
|
|
29
|
+
canUpsertClean: true, // you implement upsertClean()
|
|
30
|
+
canUpsertFieldOptions: true, // you implement upsertFieldOptions()
|
|
31
|
+
queryFields: [ /* ObjectField[] returned by query */ ],
|
|
32
|
+
upsertFields: [ /* ObjectField[] accepted by upsert */ ],
|
|
33
|
+
deleteFields: [ /* ObjectField[] needed to identify rows to delete */ ],
|
|
34
|
+
queryFilter: { // system-specific extra filters
|
|
35
|
+
rowFilter: [ { id: "modifiedOnOrAfter", type: "string" } ],
|
|
36
|
+
propertyFilter: [ { id: "fields", type: "array", arrayType: "string" } ],
|
|
37
|
+
},
|
|
38
|
+
relationships: [ // enables related-row queries
|
|
39
|
+
{ id: "companies-companyId", name: "Company", relObjectId: "companies",
|
|
40
|
+
cardinality: "OneToOne", data: { idField: "companyId" } },
|
|
41
|
+
],
|
|
42
|
+
isCustom: true, // object is account-specific (a custom object)
|
|
43
|
+
data: { /* anything YOUR connector needs back later - options.meta.data */ },
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Strive to align the `queryFields` and `upsertFields` shape (same schema). The `deleteFields` should typically contain a single row key field.
|
|
48
|
+
|
|
49
|
+
Other fields: `settingsMeta`/`settingsValues` (per-object settings), `order` (sort). Omit flags
|
|
50
|
+
rather than setting them `false` — `undefined` and `false` both mean "unsupported", and omitted
|
|
51
|
+
reads cleaner.
|
|
52
|
+
|
|
53
|
+
Only make a capability conditional when the API forces it: e.g. objects whose REST path embeds
|
|
54
|
+
`{parentId}` can't `canQueryList` (there's no "all parents" call) — give them `canQueryByIds`
|
|
55
|
+
and a `relatedFilter` path instead.
|
|
56
|
+
|
|
57
|
+
## `ObjectField`
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
{ id: "email", name: "Email", type: "string" }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Field | Meaning |
|
|
64
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `id` | property name as it appears in `row.data` - should be unique across all peer fields |
|
|
66
|
+
| `type` | `"string"` · `"number"` · `"boolean"` · `"object"` · `"array"` · `"file"` · `"any"` |
|
|
67
|
+
| `isKey: true` | this field is the row's unique id — its value becomes `row.meta.key`. Assign this (or `isUserKey`) to exactly one `queryFields`, plus one `upsertFields`, plus one `deleteFields` |
|
|
68
|
+
| `isUserKey: true` | user-supplied natural key that can both add and update — see below |
|
|
69
|
+
| `canMatch: true` | field may be referenced by match rules (set on id/name/email-like fields; apply recursively into nested object fields where meaningful) |
|
|
70
|
+
| `optionValues` | picklist options `[{ id, name?, group? }]` |
|
|
71
|
+
| `canUpdateOptions: true` | new picklist options may be added via `upsertFieldOptions()` |
|
|
72
|
+
| `objectFields` | child fields when `type: "object"` |
|
|
73
|
+
| `arrayType` / `arrayObjectFields` | element type / element fields when `type: "array"` |
|
|
74
|
+
| `constraints` | validation: `mandatory` (`"always"` · `"add"` · `"update"`), `max/min/maxLen/minLen`, `regex`, `email`, `integer`, `decimals`, `trim`, `dt: { format: "iso8601", "unix", ... }` |
|
|
75
|
+
| `isCustom` | account-specific/custom field |
|
|
76
|
+
| `data` | connector-private payload, echoed back in metadata |
|
|
77
|
+
|
|
78
|
+
### `isKey` vs `isUserKey`
|
|
79
|
+
|
|
80
|
+
- `isKey`: the system-assigned id (`"id"`, `"Id"`). Updates address rows by it. When an upsert
|
|
81
|
+
operation omits the field it should be treated as an add operation. Objects whose identity
|
|
82
|
+
needs MORE than one field (child REST resources like `/parent/{parentId}/notes`) encode the
|
|
83
|
+
parts into a single `$compositeKey` pseudo-field — see
|
|
84
|
+
[11-composite-keys.md](./11-composite-keys.md).
|
|
85
|
+
- `isUserKey`: a natural key the USER supplies (e.g. a property name) that can either add or
|
|
86
|
+
update. It is declared as an **object** field whose children carry the value and the intended
|
|
87
|
+
operation:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
upsertFields.unshift({
|
|
91
|
+
id: "name",
|
|
92
|
+
type: "object",
|
|
93
|
+
isUserKey: true,
|
|
94
|
+
objectFields: [
|
|
95
|
+
{ id: "value", name: "Field name", type: "string" },
|
|
96
|
+
{
|
|
97
|
+
id: "operation",
|
|
98
|
+
name: "Upsert operation",
|
|
99
|
+
type: "string",
|
|
100
|
+
optionValues: [{ id: "add" }, { id: "update" }],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Match rules
|
|
107
|
+
|
|
108
|
+
`matchRules` values: `"id"`, `"name[ci]"`, `"email[ci]"`, `"domain[ci]"`,
|
|
109
|
+
`"first_and_last_name[ci]"`, `"field_value_equals[ci]"` (`[ci]` = case-insensitive). Declare
|
|
110
|
+
only rules `query()` actually implements via `matchFilter` — see [05-query.md](./05-query.md).
|
|
111
|
+
|
|
112
|
+
### Relationships
|
|
113
|
+
|
|
114
|
+
Relationships are defined on the source object and declare that given a row from the source object, the relationship allows row(s) from another object (`relObjectId`) that relate to this row to be collected. The canonical example is collecting the Company record that a Contact relates to.
|
|
115
|
+
|
|
116
|
+
`{ id, name?, relObjectId, cardinality: "OneToOne" | "OneToMany", fkPath?, fkPathOn?, data? }` —
|
|
117
|
+
`id` unique per object; `data` is yours (a common pattern stores the FK field name so `query()`
|
|
118
|
+
can build the related filter). Declaring relationships enables `relatedFilter` queries.
|
|
119
|
+
|
|
120
|
+
## Building `meta()` maintainably
|
|
121
|
+
|
|
122
|
+
Never hand-maintain a big field list twice. The proven generators, in order of preference:
|
|
123
|
+
|
|
124
|
+
1. **From the API's describe/metadata endpoint** — fetch object descriptions (in parallel with
|
|
125
|
+
`Promise.all`) and map each API field to an `ObjectField`, switch-casing the API's type
|
|
126
|
+
system into the SDK's. Map reference/lookup fields into `relationships`.
|
|
127
|
+
2. **From a bundled OpenAPI spec** — load the YAML/JSON lazily and derive objects + fields from
|
|
128
|
+
its schemas.
|
|
129
|
+
3. **From a sample payload** — `SDK.utilities.fieldsFromJSON(sampleJson)` infers a full
|
|
130
|
+
`ObjectField[]` tree. Its rules: nested objects → `objectFields`; arrays → `arrayType`
|
|
131
|
+
sampled from the first non-null element (arrays of objects → `arrayObjectFields`); empty
|
|
132
|
+
`{}`, `null` and `undefined` → type `"any"`. Great for drafting; review the result.
|
|
133
|
+
|
|
134
|
+
When field lists must be hand-maintained, keep them as **one `Fields<Object>.mjs` module per
|
|
135
|
+
object**, imported by a `MetaData.mjs` that assembles `meta()` — and remember every module
|
|
136
|
+
must appear in the entry sidecar's `module_script_file_paths` (01).
|
|
137
|
+
|
|
138
|
+
Deduplicate anything the API can duplicate (e.g. `[...new Set(referenceTo)]`) and derive
|
|
139
|
+
capability flags from evidence, not optimism — e.g. `canQueryByCheckpoint` only when a
|
|
140
|
+
last-modified filter actually exists on that object.
|
|
141
|
+
|
|
142
|
+
## How `constraints` are enforced (what `verifyFieldConstraints` does)
|
|
143
|
+
|
|
144
|
+
`upsertClean`'s `verifyFieldConstraints` (06) doesn't just report — several constraints
|
|
145
|
+
**cleanse the value in place**:
|
|
146
|
+
|
|
147
|
+
- `maxLen`: longer strings are truncated to `maxLen - 3` + `"..."` (byte length).
|
|
148
|
+
- `min`/`max`: value parsed via `Number(v)`; out-of-range values are removed.
|
|
149
|
+
- `integer` / `decimals`: `Number(v)` then `Math.round` / `toFixed(decimals)`.
|
|
150
|
+
- `email`: removed when malformed or the TLD is unknown.
|
|
151
|
+
- `trim`: whitespace-trimmed.
|
|
152
|
+
- `dt`: validated/normalized per `format` (`unix`, `unix_ms`, `iso8601`, `yyyy-MM-dd`, ...,
|
|
153
|
+
or `custom` + `pattern`), plus `future`/`past`/`weekday`/`from`/`to`/`rangeYears` checks.
|
|
154
|
+
- `mandatory` (`"always" | "add" | "update"`) raises a `ValueRequired` issue — and
|
|
155
|
+
`mandatoryAccepts: ["null" | "blank_string"]` lets a mandatory field treat `null`/`""` as a
|
|
156
|
+
satisfying value (for APIs where "explicitly blank" is meaningful).
|
|
157
|
+
|
|
158
|
+
## `identity`
|
|
159
|
+
|
|
160
|
+
Return a stable account identity string, e.g. `` `${org.name}|${user.username}` ``. The platform
|
|
161
|
+
compares it across refreshes to detect a connection re-pointed at a different account. The test
|
|
162
|
+
harness pins it via `expectedIdentity` ([09-testing.md](./09-testing.md)).
|