@memberjunction/connector-reply 1.0.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/dist/ReplyConnector.d.ts +355 -0
- package/dist/ReplyConnector.js +1058 -0
- package/dist/ReplyConnector.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
8
|
+
import { Metadata } from '@memberjunction/core';
|
|
9
|
+
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
10
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
11
|
+
import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
|
|
12
|
+
/**
|
|
13
|
+
* 5.x COMPATIBILITY SHIM for the engine's shared `buildAPIKeyHeaderValue` auth-helper.
|
|
14
|
+
*
|
|
15
|
+
* The shared helper (`auth-helpers/APIKeyHeaderBuilder`) landed AFTER 5.51.0, the newest published
|
|
16
|
+
* `@memberjunction/integration-engine` — it exists only in the unreleased 6.x line. This package pins the
|
|
17
|
+
* 5.x range so it installs today, so importing the shared helper here would not resolve at all.
|
|
18
|
+
*
|
|
19
|
+
* This is a byte-for-byte reimplementation of that helper's `buildAPIKeyHeaderValue`, guards INCLUDED:
|
|
20
|
+
* the empty-key rejection and the CR/LF header-injection rejection are the whole point of centralizing it,
|
|
21
|
+
* so dropping them for convenience would be a security regression, not a simplification.
|
|
22
|
+
*
|
|
23
|
+
* REPLACE this shim with the shared import the moment an engine version exporting it is published — the
|
|
24
|
+
* point of the helper is that no connector hand-maintains this logic.
|
|
25
|
+
*/
|
|
26
|
+
function buildAPIKeyHeaderValue(req) {
|
|
27
|
+
const HEADER_INJECTION = /[\r\n]/;
|
|
28
|
+
const key = (req.ApiKey ?? '').trim();
|
|
29
|
+
if (key.length === 0) {
|
|
30
|
+
throw new Error('APIKeyHeaderBuilder: ApiKey is required and cannot be empty.');
|
|
31
|
+
}
|
|
32
|
+
if (HEADER_INJECTION.test(key)) {
|
|
33
|
+
throw new Error('APIKeyHeaderBuilder: ApiKey must not contain CR/LF characters.');
|
|
34
|
+
}
|
|
35
|
+
const prefix = (req.ValuePrefix ?? '').trim();
|
|
36
|
+
if (prefix.length > 0 && HEADER_INJECTION.test(prefix)) {
|
|
37
|
+
throw new Error('APIKeyHeaderBuilder: ValuePrefix must not contain CR/LF characters.');
|
|
38
|
+
}
|
|
39
|
+
return prefix.length > 0 ? `${prefix} ${key}` : key;
|
|
40
|
+
}
|
|
41
|
+
// ─── Constants ────────────────────────────────────────────────────────
|
|
42
|
+
/** Vendor default host. Overridable per-connection by data (Configuration.BaseURL) — never hardcoded downstream. */
|
|
43
|
+
const REPLY_DEFAULT_BASE_URL = 'https://api.reply.io';
|
|
44
|
+
/**
|
|
45
|
+
* Absolute ceiling for the `top` page-size parameter. The spec documents `top` as "max 1000" on the
|
|
46
|
+
* collection endpoints; per-endpoint ceilings that are LOWER are carried per-IO in DefaultPageSize and win.
|
|
47
|
+
*/
|
|
48
|
+
const REPLY_MAX_TOP = 1000;
|
|
49
|
+
/** Page size used when an IO declares pagination but no explicit ceiling — deliberately conservative. */
|
|
50
|
+
const REPLY_FALLBACK_TOP = 100;
|
|
51
|
+
/** Reply.io credential-verification endpoint. Documented `x-required-scope: none` — any valid key may call it. */
|
|
52
|
+
const REPLY_WHOAMI_PATH = '/v3/whoami';
|
|
53
|
+
/**
|
|
54
|
+
* RATE-LIMIT PACING (not a catalog): request PATH families the vendor documents as more expensive than an
|
|
55
|
+
* ordinary collection read — reporting and the per-entity statistics/state rollups. The frozen contract
|
|
56
|
+
* carries ONE uniform budget for every IO (`Configuration.rateLimit` = 100/min + 3,000/hr on all 84), so
|
|
57
|
+
* this is not an object/field claim and nothing here declares what exists; it only slows THIS connector's
|
|
58
|
+
* own request rate on the families vendor guidance calls out, so a stats-heavy walk does not consume the
|
|
59
|
+
* shared per-user budget faster than the rest of the sync can tolerate.
|
|
60
|
+
*/
|
|
61
|
+
const REPLY_STRICT_PACED_URL_PATTERNS = [
|
|
62
|
+
/\/v3\/reporting\//i, // /v3/reporting/* — reporting surface
|
|
63
|
+
/\/stats(?:[/?]|$)/i, // .../{id}/stats — e.g. email-account + sequence statistics
|
|
64
|
+
/\/statuses(?:[/?]|$)/i, // .../{id}/statuses — per-contact status rollup
|
|
65
|
+
/\/contacts\/state(?:[/?]|$)/i // /v3/sequences/{id}/contacts/state
|
|
66
|
+
];
|
|
67
|
+
/**
|
|
68
|
+
* Minimum gap between two STRICT-family requests (≈60/min for those paths, vs the vendor's 100/min overall).
|
|
69
|
+
* Applies only to this connector's own strict-family calls; the engine's AIMD bucket still governs globally.
|
|
70
|
+
*/
|
|
71
|
+
const REPLY_STRICT_FAMILY_MIN_GAP_MS = 1000;
|
|
72
|
+
// ─── ReplyConnector ────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Reply.io connector — extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
|
|
75
|
+
*
|
|
76
|
+
* Discovery, template-var read traversal (second-layer objects resolve their parent through
|
|
77
|
+
* Configuration.parentObjectName), the paginated GET loop and the generic per-operation CRUD dispatch are
|
|
78
|
+
* all INHERITED. This class supplies the Reply-specific protocol surface: Bearer auth, top/skip offset
|
|
79
|
+
* pagination with per-endpoint ceilings, cross-page PK dedupe, embedded-array projection, RFC 9457 problem
|
|
80
|
+
* handling with entitlement (403) separation, non-atomic bulk outcome assertion, and the §7/§10
|
|
81
|
+
* sync-efficiency hooks the frozen contract evidences.
|
|
82
|
+
*/
|
|
83
|
+
let ReplyConnector = class ReplyConnector extends BaseRESTIntegrationConnector {
|
|
84
|
+
constructor() {
|
|
85
|
+
super(...arguments);
|
|
86
|
+
/** Cached auth for the lifetime of a sync run — Reply API keys are static, there is no refresh step. */
|
|
87
|
+
this.cachedAuth = null;
|
|
88
|
+
/** Last non-2xx problem seen on the wire, used to explain an empty fetch instead of failing silently. */
|
|
89
|
+
this.lastProblem = null;
|
|
90
|
+
/**
|
|
91
|
+
* `Retry-After` (in ms) captured from the most recent 429 seen on the wire, held for exactly ONE read.
|
|
92
|
+
*
|
|
93
|
+
* WHY this exists: the inherited read path validates a non-2xx by throwing a PLAIN `Error` carrying only
|
|
94
|
+
* a status + body preview — the response HEADERS are gone by the time the engine calls
|
|
95
|
+
* `ExtractRetryAfterMs(error)`. Without this capture the vendor's own `Retry-After` instruction is
|
|
96
|
+
* silently discarded on every throttled read and the engine falls back to a generic backoff curve,
|
|
97
|
+
* which is precisely what the contract forbids (100/min AND 3,000/hr are SHARED per user — guessing the
|
|
98
|
+
* wait either wastes the client's quota or hammers a closed window). Captured at the wire boundary,
|
|
99
|
+
* consumed once, then cleared so a stale value can never be replayed against a later error.
|
|
100
|
+
*/
|
|
101
|
+
this.pendingRetryAfterMs = null;
|
|
102
|
+
/** Wall-clock ms at which the last STRICT-family (reporting/stats) request was issued. */
|
|
103
|
+
this.lastStrictFamilyRequestAt = 0;
|
|
104
|
+
}
|
|
105
|
+
// ── Identity (T1 three-way invariant) ────────────────────────────
|
|
106
|
+
/** Verbatim `MJ: Integrations.Name`. Load-bearing: the T1 three-way check compares this === metadata Name. */
|
|
107
|
+
get IntegrationName() {
|
|
108
|
+
return 'Reply';
|
|
109
|
+
}
|
|
110
|
+
// ── Capability getters (kept in lockstep with the per-operation metadata columns) ──
|
|
111
|
+
//
|
|
112
|
+
// 34 of the 84 emitted IOs carry SupportsCreate|SupportsUpdate|SupportsDelete with populated
|
|
113
|
+
// Create*/Update*/Delete* columns, so all three verbs are genuinely available. Per-object capability is
|
|
114
|
+
// still enforced by the generic dispatch, which throws when an object's columns are null.
|
|
115
|
+
get SupportsCreate() { return true; }
|
|
116
|
+
get SupportsUpdate() { return true; }
|
|
117
|
+
get SupportsDelete() { return true; }
|
|
118
|
+
/**
|
|
119
|
+
* Discovery is NON-authoritative. Reply.io publishes no describe-all endpoint (Configuration
|
|
120
|
+
* DiscoveryIsAuthoritative=false in the frozen contract; the 270-path spec has no introspection route),
|
|
121
|
+
* so a refresh can only re-yield what is already Active. Absence in a refresh proves nothing → never
|
|
122
|
+
* deactivate an object on its basis.
|
|
123
|
+
*/
|
|
124
|
+
get DiscoveryIsAuthoritative() {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Sample-union enrichment (MJ connector standard). The Declared metadata is spec-derived and cannot know
|
|
129
|
+
* a tenant's CUSTOM fields (Reply exposes user-defined custom fields on contacts). After the base
|
|
130
|
+
* cache-driven introspection, each object's live read shape is sampled via `DiscoverFieldsViaFetch` and
|
|
131
|
+
* UNIONed into the declared set with `mergeDeclaredWithSampledFields` (never-shrink, declared-wins).
|
|
132
|
+
* Best-effort + parallel — a sample failure (or an unentitled 403 family) leaves the declared set
|
|
133
|
+
* untouched. Overrides `IntrospectSchema`, NOT `DiscoverFields` (that would recurse into
|
|
134
|
+
* `DiscoverFieldsViaFetch`'s own fallback). Connector-agnostic: no Reply-specific field logic.
|
|
135
|
+
*/
|
|
136
|
+
async IntrospectSchema(companyIntegration, contextUser) {
|
|
137
|
+
const info = await super.IntrospectSchema(companyIntegration, contextUser);
|
|
138
|
+
await Promise.all(info.Objects.map(async (obj) => {
|
|
139
|
+
try {
|
|
140
|
+
const sampled = await this.DiscoverFieldsViaFetch(companyIntegration, obj.ExternalName, contextUser);
|
|
141
|
+
obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, sampled);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* best-effort — a sample failure leaves the declared fields as-is */
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
return info;
|
|
148
|
+
}
|
|
149
|
+
// ── Sync-efficiency hooks (§7/§10 — each backed by a frozen-contract fact) ──
|
|
150
|
+
/**
|
|
151
|
+
* From every IO's Configuration.rateLimit: 100 requests/minute AND 3,000/hour, SHARED per API user
|
|
152
|
+
* across all of that user's tools. 100/min ≈ 1.67/s; we publish 1.5/s with a small burst so the hourly
|
|
153
|
+
* ceiling (0.83/s sustained) is approached conservatively rather than sprinting into a 429 wall. The
|
|
154
|
+
* engine's AIMD bucket cuts on 429 (honoring Retry-After below) and ramps back slowly.
|
|
155
|
+
*/
|
|
156
|
+
get RateLimitPolicy() {
|
|
157
|
+
return {
|
|
158
|
+
TokensPerSec: 1.5,
|
|
159
|
+
Burst: 5,
|
|
160
|
+
ThrottleBackoffFactor: 0.5,
|
|
161
|
+
SuccessRampPerCall: 0.05,
|
|
162
|
+
MinTokensPerSec: 0.25,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Reply.io returns `Retry-After` (integer SECONDS, minimum 1) on 429 — the vendor's own instruction, so
|
|
167
|
+
* it is honored exactly rather than approximated with a local backoff curve.
|
|
168
|
+
*
|
|
169
|
+
* Two sources, in order: (1) headers carried ON the error, when the caller surfaced a rich error; (2) the
|
|
170
|
+
* value captured at the wire boundary on the last 429, because the inherited read path throws a plain
|
|
171
|
+
* `Error` with the headers already discarded. The captured value is consumed once and cleared, so it can
|
|
172
|
+
* never be replayed against an unrelated later error.
|
|
173
|
+
*/
|
|
174
|
+
ExtractRetryAfterMs(error) {
|
|
175
|
+
const fromHeaders = this.retryAfterFromHeaders(this.extractHeadersFromError(error));
|
|
176
|
+
if (fromHeaders !== undefined) {
|
|
177
|
+
this.pendingRetryAfterMs = null;
|
|
178
|
+
return fromHeaders;
|
|
179
|
+
}
|
|
180
|
+
const captured = this.pendingRetryAfterMs;
|
|
181
|
+
this.pendingRetryAfterMs = null;
|
|
182
|
+
return captured ?? undefined;
|
|
183
|
+
}
|
|
184
|
+
/** Parses a `Retry-After` header (integer seconds per the vendor) into ms; undefined when absent/unparseable. */
|
|
185
|
+
retryAfterFromHeaders(headers) {
|
|
186
|
+
if (!headers)
|
|
187
|
+
return undefined;
|
|
188
|
+
const raw = headers['retry-after'] ?? headers['Retry-After'];
|
|
189
|
+
if (raw == null)
|
|
190
|
+
return undefined;
|
|
191
|
+
const secs = Number(raw);
|
|
192
|
+
return Number.isFinite(secs) && secs >= 0 ? Math.ceil(secs * 1000) : undefined;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Deliberately LOW. The rate budget is per-user and shared, and the vendor's guidance is sequential /
|
|
196
|
+
* low-concurrency access; parallelism here buys nothing but 429s against a 100/min ceiling.
|
|
197
|
+
*/
|
|
198
|
+
get MaxConcurrencyHint() { return 2; }
|
|
199
|
+
/**
|
|
200
|
+
* Every object is no-watermark (FullPullHashDiff), so resume relies on the object's declared stable
|
|
201
|
+
* ordering key (the extractor emitted `StableOrderingKey` per IO — usually `id`). Returns null when the
|
|
202
|
+
* object declares none and has no single primary key.
|
|
203
|
+
*/
|
|
204
|
+
StableOrderingKey(objectName) {
|
|
205
|
+
const obj = this.tryGetCachedObject(objectName);
|
|
206
|
+
if (!obj)
|
|
207
|
+
return null;
|
|
208
|
+
const declared = obj.StableOrderingKey;
|
|
209
|
+
if (declared && declared.trim().length > 0)
|
|
210
|
+
return declared.trim();
|
|
211
|
+
const pk = this.GetCachedFields(obj.ID).find(f => f.IsPrimaryKey);
|
|
212
|
+
return pk?.Name ?? null;
|
|
213
|
+
}
|
|
214
|
+
// ── Abstract REST hooks ──────────────────────────────────────────
|
|
215
|
+
/**
|
|
216
|
+
* Resolves the Bearer credential from the linked Credential entity (preferred — `apiKey`, per the
|
|
217
|
+
* "API Key" credential type schema) or the CompanyIntegration Configuration JSON (fallback). Cached for
|
|
218
|
+
* the run. There is no token exchange, no signature and no expiry, so no auth-helper grant flow applies.
|
|
219
|
+
*/
|
|
220
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
221
|
+
if (this.cachedAuth)
|
|
222
|
+
return this.cachedAuth;
|
|
223
|
+
const creds = await this.loadCredentials(companyIntegration, contextUser);
|
|
224
|
+
if (!creds.ApiKey) {
|
|
225
|
+
throw new Error('Reply.io credential incomplete: attach a credential of type "API Key" carrying `apiKey`, ' +
|
|
226
|
+
'or set the connection Configuration key "ApiKey".');
|
|
227
|
+
}
|
|
228
|
+
this.cachedAuth = {
|
|
229
|
+
// Composed by the SHARED auth-helper (`buildAPIKeyHeaderValue`), not a hand-built literal: it
|
|
230
|
+
// enforces the non-empty-key and CR/LF header-injection guards centrally. Reply's key is a static
|
|
231
|
+
// opaque bearer token — there is no token exchange, signature or refresh, so no crypto is involved
|
|
232
|
+
// and no OAuth grant helper applies.
|
|
233
|
+
AuthHeader: buildAPIKeyHeaderValue({
|
|
234
|
+
HeaderName: 'Authorization',
|
|
235
|
+
ApiKey: creds.ApiKey,
|
|
236
|
+
ValuePrefix: 'Bearer',
|
|
237
|
+
}),
|
|
238
|
+
BaseURLOverride: this.resolveBaseURLOverride(companyIntegration) ?? undefined,
|
|
239
|
+
};
|
|
240
|
+
return this.cachedAuth;
|
|
241
|
+
}
|
|
242
|
+
/** Static header set — the Bearer value is composed once in Authenticate. */
|
|
243
|
+
BuildHeaders(auth) {
|
|
244
|
+
return {
|
|
245
|
+
'Authorization': auth.AuthHeader,
|
|
246
|
+
'Content-Type': 'application/json',
|
|
247
|
+
'Accept': 'application/json, application/problem+json',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* HTTP transport (fetch). Owns the wire boundary; test subclasses override this to capture requests.
|
|
252
|
+
*
|
|
253
|
+
* CRITICAL: a Reply.io 401 is documented to carry an EMPTY BODY (the scheme is signalled by the
|
|
254
|
+
* `WWW-Authenticate` header). Parsing is therefore guarded on a non-empty payload — `JSON.parse('')`
|
|
255
|
+
* on the most common failure path would crash the connector before it could report the auth failure.
|
|
256
|
+
* Any non-2xx is recorded (status + parsed problem) so FetchChanges can explain an empty result.
|
|
257
|
+
*/
|
|
258
|
+
async MakeHTTPRequest(_auth, url, method, headers, body) {
|
|
259
|
+
await this.PaceStrictFamily(url);
|
|
260
|
+
const response = await fetch(url, {
|
|
261
|
+
method,
|
|
262
|
+
headers,
|
|
263
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
264
|
+
});
|
|
265
|
+
const respHeaders = {};
|
|
266
|
+
response.headers.forEach((v, k) => { respHeaders[k.toLowerCase()] = v; });
|
|
267
|
+
const text = await response.text();
|
|
268
|
+
let parsed = null;
|
|
269
|
+
if (text.trim().length > 0) {
|
|
270
|
+
try {
|
|
271
|
+
parsed = JSON.parse(text);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
parsed = text;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const result = { Status: response.status, Body: parsed, Headers: respHeaders };
|
|
278
|
+
this.recordProblem(result, url);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Extra self-throttle for the reporting / statistics families. The rate budget (100/min AND 3,000/hr) is
|
|
283
|
+
* SHARED per API user across every tool that key drives, and these paths are the expensive ones — so this
|
|
284
|
+
* connector paces them to roughly one request per second rather than letting a stats-heavy object sprint
|
|
285
|
+
* the shared budget into a 429 wall that then stalls the whole sync.
|
|
286
|
+
*
|
|
287
|
+
* Deliberately NOT a retry and NOT a backoff: 429 handling stays with the engine's AIMD bucket +
|
|
288
|
+
* `ExtractRetryAfterMs` (which honors the vendor's `Retry-After` exactly). This is pure pre-emptive
|
|
289
|
+
* spacing, and it is a no-op for every ordinary collection read.
|
|
290
|
+
*/
|
|
291
|
+
async PaceStrictFamily(url) {
|
|
292
|
+
if (!REPLY_STRICT_PACED_URL_PATTERNS.some(re => re.test(url)))
|
|
293
|
+
return;
|
|
294
|
+
const now = this.NowMs();
|
|
295
|
+
const waitMs = REPLY_STRICT_FAMILY_MIN_GAP_MS - (now - this.lastStrictFamilyRequestAt);
|
|
296
|
+
if (this.lastStrictFamilyRequestAt > 0 && waitMs > 0)
|
|
297
|
+
await this.Sleep(waitMs);
|
|
298
|
+
this.lastStrictFamilyRequestAt = this.NowMs();
|
|
299
|
+
}
|
|
300
|
+
/** Clock seam — overridable so a test can assert pacing without real elapsed time. */
|
|
301
|
+
NowMs() { return Date.now(); }
|
|
302
|
+
/** Sleep seam — overridable so a test can assert pacing without actually waiting. */
|
|
303
|
+
Sleep(ms) {
|
|
304
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Strips the Reply.io list envelope. Resolution order (deterministic — never "pick any array"):
|
|
308
|
+
* 1. the object's declared ResponseDataKey, when it names an array;
|
|
309
|
+
* 2. a bare array body;
|
|
310
|
+
* 3. the vendor-wide `items` envelope key (documented on every paginated collection);
|
|
311
|
+
* 4. a single non-enveloped object → a one-record array (get-one and the singleton doors such as
|
|
312
|
+
* `/v3/contacts/{id}/statuses`, `/v3/whoami`, `/v3/sequence-templates`).
|
|
313
|
+
*/
|
|
314
|
+
NormalizeResponse(rawBody, responseDataKey) {
|
|
315
|
+
if (rawBody == null)
|
|
316
|
+
return [];
|
|
317
|
+
if (Array.isArray(rawBody))
|
|
318
|
+
return rawBody;
|
|
319
|
+
if (typeof rawBody !== 'object')
|
|
320
|
+
return [];
|
|
321
|
+
const body = rawBody;
|
|
322
|
+
if (responseDataKey) {
|
|
323
|
+
const declared = body[responseDataKey];
|
|
324
|
+
if (Array.isArray(declared))
|
|
325
|
+
return declared;
|
|
326
|
+
}
|
|
327
|
+
const items = body.items;
|
|
328
|
+
if (Array.isArray(items))
|
|
329
|
+
return items;
|
|
330
|
+
return [body];
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Reply.io pagination is OFFSET ONLY. Continuation is the envelope's explicit `hasMore` boolean; when a
|
|
334
|
+
* response omits it (the non-enveloped singleton doors) a full page is treated as "more may follow" and
|
|
335
|
+
* a short page ends the walk. Cursor / page-number never occur in this vendor's surface.
|
|
336
|
+
*/
|
|
337
|
+
ExtractPaginationInfo(rawBody, paginationType, _currentPage, currentOffset, pageSize) {
|
|
338
|
+
if (paginationType !== 'Offset' || !rawBody || typeof rawBody !== 'object')
|
|
339
|
+
return { HasMore: false };
|
|
340
|
+
const env = rawBody;
|
|
341
|
+
const count = Array.isArray(env.items) ? env.items.length : (Array.isArray(rawBody) ? rawBody.length : 0);
|
|
342
|
+
const hasMore = typeof env.hasMore === 'boolean'
|
|
343
|
+
? env.hasMore
|
|
344
|
+
: (pageSize > 0 && count >= pageSize);
|
|
345
|
+
return { HasMore: hasMore, NextOffset: currentOffset + count };
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Reply.io offset params are `top` (page size) + `skip` (offset) — NOT the base defaults `limit`/`offset`.
|
|
349
|
+
* The page size is clamped to the endpoint's DECLARED ceiling (IO.DefaultPageSize, probe-confirmed) and
|
|
350
|
+
* to the vendor-wide documented maximum, so a batch-capacity request can never exceed what the endpoint
|
|
351
|
+
* accepts (an over-large `top` is a 400 `*.invalidPagination`, not a silent truncation).
|
|
352
|
+
*/
|
|
353
|
+
BuildPaginatedURL(basePath, obj, _page, offset, _cursor, effectivePageSize) {
|
|
354
|
+
if (obj.PaginationType !== 'Offset')
|
|
355
|
+
return basePath;
|
|
356
|
+
const ceiling = Math.min(obj.DefaultPageSize ?? REPLY_FALLBACK_TOP, REPLY_MAX_TOP);
|
|
357
|
+
const top = Math.max(1, Math.min(effectivePageSize ?? ceiling, ceiling));
|
|
358
|
+
const separator = basePath.includes('?') ? '&' : '?';
|
|
359
|
+
return `${basePath}${separator}top=${top}&skip=${offset}`;
|
|
360
|
+
}
|
|
361
|
+
/** Base host for every request. A Configuration override (sandbox / spec-mock) wins over the vendor host. */
|
|
362
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
363
|
+
const ctx = auth;
|
|
364
|
+
if (ctx.BaseURLOverride)
|
|
365
|
+
return ctx.BaseURLOverride.replace(/\/+$/, '');
|
|
366
|
+
return REPLY_DEFAULT_BASE_URL;
|
|
367
|
+
}
|
|
368
|
+
// ── FetchChanges (embedded projection + cross-page dedupe + entitlement reporting) ──
|
|
369
|
+
/**
|
|
370
|
+
* OVERRIDDEN for three Reply-specific read concerns; everything else delegates to the inherited base
|
|
371
|
+
* GET path (which uses the pagination overrides above and resolves template vars per-parent).
|
|
372
|
+
*
|
|
373
|
+
* 1. EMBEDDED-ARRAY PROJECTION — 17 of the 84 IOs have no endpoint of their own: their records live
|
|
374
|
+
* inside another object's payload, declared as `Configuration.resourceKey = "<Owner>.<key>[]"`
|
|
375
|
+
* (e.g. `Contact.customFields[]`, `HolidayCalendar.holidays[]`, `SequenceStep.variants[]`). For those
|
|
376
|
+
* we fetch the OWNER through the inherited path — so the owner's own pagination, parent-iteration and
|
|
377
|
+
* resume state all apply — then descend the declared key to emit the leaf records.
|
|
378
|
+
* 2. CROSS-PAGE PRIMARY-KEY DEDUPE — offset paging over a mutating collection shifts the window, so the
|
|
379
|
+
* same record can appear on two pages of one walk. Records are de-duplicated by ExternalID.
|
|
380
|
+
* 3. ENTITLEMENT REPORTING — a 403 means "reachable but not entitled" (missing scope / plan feature).
|
|
381
|
+
* The base skips 403 objects with a console.warn; we additionally attach a structured FetchWarning so
|
|
382
|
+
* an unentitled family is REPORTED, not silently dropped and not counted as a pass.
|
|
383
|
+
*/
|
|
384
|
+
async FetchChanges(ctx) {
|
|
385
|
+
const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
386
|
+
const projection = this.resolveEmbeddedProjection(obj);
|
|
387
|
+
this.lastProblem = null;
|
|
388
|
+
// 4. PER-PAGE CHECKPOINTING — see ClampBatchToOnePage. The walked object is the CONTAINER for a
|
|
389
|
+
// projection (its endpoint is what actually pages), else the object itself.
|
|
390
|
+
const walked = projection ? this.resolveContainerObject(obj, projection) : obj;
|
|
391
|
+
const pagedCtx = this.ClampBatchToOnePage(ctx, walked);
|
|
392
|
+
const result = projection
|
|
393
|
+
? await this.fetchProjected(obj, projection, pagedCtx)
|
|
394
|
+
: await super.FetchChanges(pagedCtx);
|
|
395
|
+
const deduped = this.dedupeByExternalID(result.Records);
|
|
396
|
+
const warnings = this.collectWarnings(obj, deduped.length, result.Warnings);
|
|
397
|
+
return { ...result, Records: deduped, Warnings: warnings };
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Caps a fetch call to ONE endpoint page so the engine checkpoints after every page.
|
|
401
|
+
*
|
|
402
|
+
* The inherited pagination loop keeps requesting pages until it has accumulated `BatchSize` records,
|
|
403
|
+
* and only THEN returns `NextOffset` for the engine to persist. With Reply.io's 3,000-requests/hour
|
|
404
|
+
* ceiling a first full sync of a large tenant runs for HOURS, and an interruption anywhere inside that
|
|
405
|
+
* loop discards every page fetched since the last engine checkpoint — pages that cost real, shared,
|
|
406
|
+
* per-user quota that cannot be bought back. Clamping the batch to the endpoint's own page ceiling
|
|
407
|
+
* makes the loop return after a single request, so `NextOffset` is persisted per PAGE and a resumed
|
|
408
|
+
* sync re-requests at most one page. Cost: nothing — the same number of HTTP requests either way.
|
|
409
|
+
*
|
|
410
|
+
* No-op for non-offset / non-paginated objects (single-shot doors) and when the engine already asked
|
|
411
|
+
* for a batch at or below the page ceiling.
|
|
412
|
+
*/
|
|
413
|
+
ClampBatchToOnePage(ctx, walked) {
|
|
414
|
+
if (!walked.SupportsPagination || walked.PaginationType !== 'Offset')
|
|
415
|
+
return ctx;
|
|
416
|
+
const ceiling = Math.min(walked.DefaultPageSize ?? REPLY_FALLBACK_TOP, REPLY_MAX_TOP);
|
|
417
|
+
const batch = ctx.BatchSize > 0 ? Math.min(ctx.BatchSize, ceiling) : ceiling;
|
|
418
|
+
return batch === ctx.BatchSize ? ctx : { ...ctx, BatchSize: batch };
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Reads the embedded-projection declaration off an IO. The extractor writes
|
|
422
|
+
* `Configuration.resourceKey = "<OwnerObjectName>.<nestedKey>[]"` for an object that is an array nested
|
|
423
|
+
* inside another payload, and a plain slash-path (`"contacts/statuses"`) for one that has its own
|
|
424
|
+
* endpoint — so the two cases are distinguished by DECLARED metadata, never by guessing at the URL.
|
|
425
|
+
*/
|
|
426
|
+
resolveEmbeddedProjection(obj) {
|
|
427
|
+
const resourceKey = this.readConfigString(obj, 'resourceKey');
|
|
428
|
+
if (!resourceKey)
|
|
429
|
+
return null;
|
|
430
|
+
// The OWNER segment may be an object Name (`Contact`, `HolidayCalendar`) OR a hyphenated resource
|
|
431
|
+
// slug when the projection has no owning IO row (`sequence-templates.globalTemplates[]`). Both are
|
|
432
|
+
// declared forms the extractor emits, so the owner charset must admit `-`; a `[A-Za-z0-9_]+`-only
|
|
433
|
+
// owner silently rejected the slug form and sent those objects down the ordinary endpoint path,
|
|
434
|
+
// where the container payload itself was emitted as a single record instead of its nested array.
|
|
435
|
+
// The NESTED-KEY segment stays strict (a JSON property name) so an ordinary slash-path resourceKey
|
|
436
|
+
// such as "contacts/statuses" can never be misread as a projection.
|
|
437
|
+
const m = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_]+)\[\]$/.exec(resourceKey);
|
|
438
|
+
if (!m)
|
|
439
|
+
return null;
|
|
440
|
+
return { OwnerName: m[1], NestedKey: m[2] };
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Fetches an embedded-projection object: pull the CONTAINER records through the inherited fetch path,
|
|
444
|
+
* then descend the declared nested key on each one to emit the leaf records.
|
|
445
|
+
*
|
|
446
|
+
* The container is the OWNER object named in `resourceKey` when that object exists and shares this
|
|
447
|
+
* object's APIPath (so the owner's declared pagination/parent-iteration governs the walk); otherwise —
|
|
448
|
+
* e.g. the sequence-template groups, whose owner is declared as `null` — the projection's own IO row is
|
|
449
|
+
* the container. Both cases read only DECLARED metadata.
|
|
450
|
+
*/
|
|
451
|
+
async fetchProjected(obj, projection, ctx) {
|
|
452
|
+
const container = this.resolveContainerObject(obj, projection);
|
|
453
|
+
const containerCtx = { ...ctx, ObjectName: container.Name };
|
|
454
|
+
const containerResult = await super.FetchChanges(containerCtx);
|
|
455
|
+
const fields = this.GetCachedFields(obj.ID);
|
|
456
|
+
const declaredNames = new Set(fields.map(f => f.Name));
|
|
457
|
+
const pkFieldNames = this.pkFieldNames(fields);
|
|
458
|
+
const leaves = [];
|
|
459
|
+
for (const containerRecord of containerResult.Records) {
|
|
460
|
+
const nested = containerRecord.Fields[projection.NestedKey];
|
|
461
|
+
if (!Array.isArray(nested))
|
|
462
|
+
continue;
|
|
463
|
+
for (const raw of nested) {
|
|
464
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
465
|
+
continue;
|
|
466
|
+
const leaf = this.inheritDeclaredOwnerKeys({ ...raw }, containerRecord.Fields, declaredNames);
|
|
467
|
+
const transformed = this.applyTransformPreservingKeys(leaf, obj, fields);
|
|
468
|
+
leaves.push(this.buildExternalRecord(transformed, obj.Name, pkFieldNames));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
Records: leaves,
|
|
473
|
+
HasMore: containerResult.HasMore,
|
|
474
|
+
NextOffset: containerResult.NextOffset,
|
|
475
|
+
NextPage: containerResult.NextPage,
|
|
476
|
+
NextCursor: containerResult.NextCursor,
|
|
477
|
+
NextAfterKeyValue: containerResult.NextAfterKeyValue,
|
|
478
|
+
Warnings: containerResult.Warnings,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** The IO whose endpoint actually returns the container payload for a projection (owner, else self). */
|
|
482
|
+
resolveContainerObject(obj, projection) {
|
|
483
|
+
const owner = this.tryGetCachedObject(projection.OwnerName);
|
|
484
|
+
if (owner && owner.APIPath === obj.APIPath)
|
|
485
|
+
return owner;
|
|
486
|
+
return obj;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Copies owner-record keys down onto a leaf record — but ONLY keys the leaf object DECLARES as its own
|
|
490
|
+
* fields and does not already carry. This links a nested row back to its owner (the parent id the base
|
|
491
|
+
* tagged onto the container) without inventing columns: an undeclared owner key is never copied.
|
|
492
|
+
*/
|
|
493
|
+
inheritDeclaredOwnerKeys(leaf, ownerFields, declaredNames) {
|
|
494
|
+
for (const name of declaredNames) {
|
|
495
|
+
if (name in leaf)
|
|
496
|
+
continue;
|
|
497
|
+
const v = ownerFields[name];
|
|
498
|
+
if (v === undefined || v === null || typeof v === 'object')
|
|
499
|
+
continue;
|
|
500
|
+
leaf[name] = v;
|
|
501
|
+
}
|
|
502
|
+
return leaf;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Drops repeats of the same primary key WITHIN one fetch batch — which is exactly "across pages", since
|
|
506
|
+
* the inherited loop accumulates every page of the walk into one batch. Records with no resolvable
|
|
507
|
+
* ExternalID are passed through untouched (they cannot be compared, and silently collapsing them would
|
|
508
|
+
* lose data). Across BATCHES the engine's content-hash idempotency makes a repeat a no-op update.
|
|
509
|
+
*/
|
|
510
|
+
dedupeByExternalID(records) {
|
|
511
|
+
const seen = new Set();
|
|
512
|
+
const out = [];
|
|
513
|
+
for (const r of records) {
|
|
514
|
+
if (!r.ExternalID) {
|
|
515
|
+
out.push(r);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (seen.has(r.ExternalID))
|
|
519
|
+
continue;
|
|
520
|
+
seen.add(r.ExternalID);
|
|
521
|
+
out.push(r);
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Turns an observed 403/401 into a structured warning when the fetch produced nothing, so an unentitled
|
|
527
|
+
* or unauthenticated family is REPORTED rather than read as "this object is legitimately empty".
|
|
528
|
+
* 403 is explicitly NOT an invalid credential and NOT a connector defect.
|
|
529
|
+
*/
|
|
530
|
+
collectWarnings(obj, recordCount, existing) {
|
|
531
|
+
const warnings = existing ? [...existing] : [];
|
|
532
|
+
const parentIssue = this.parentDeclarationWarning(obj);
|
|
533
|
+
if (parentIssue)
|
|
534
|
+
warnings.push(parentIssue);
|
|
535
|
+
const problem = this.lastProblem;
|
|
536
|
+
// 403 is the ONLY non-2xx that reaches here: the base validates every other status and throws,
|
|
537
|
+
// whereas a 403 is swallowed into an empty result — exactly the silent drop this warning prevents.
|
|
538
|
+
if (problem && problem.Status === 403 && recordCount === 0) {
|
|
539
|
+
warnings.push({
|
|
540
|
+
Code: 'NOT_ENTITLED',
|
|
541
|
+
Message: `"${obj.Name}": Reply.io returned HTTP 403 — the path is reachable but this tenant/API key is ` +
|
|
542
|
+
`NOT ENTITLED to it (missing scope, or the feature is not on the plan). This is neither an ` +
|
|
543
|
+
`invalid credential nor a connector defect; grant the scope or leave the object inactive. ` +
|
|
544
|
+
`Vendor code: ${problem.Problem?.code ?? '(none)'}.`,
|
|
545
|
+
Data: { object: obj.Name, status: 403, code: problem.Problem?.code, detail: problem.Problem?.detail },
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return warnings.length > 0 ? warnings : undefined;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Diagnoses a parent-iterated object whose DECLARED parent cannot actually yield the ids its path
|
|
552
|
+
* needs, and reports it as a structured warning.
|
|
553
|
+
*
|
|
554
|
+
* This does NOT repair the declaration — a wrong `Configuration.parentObjectName` is an upstream
|
|
555
|
+
* metadata defect that belongs in the extractor's amendment loop, and guessing a replacement here is
|
|
556
|
+
* exactly the silent cross-owner corruption the base class refuses to commit. What it prevents is the
|
|
557
|
+
* WORSE outcome: such an object fetches zero rows (the parent yields no usable ids) and, with no
|
|
558
|
+
* warning attached, that empty result is indistinguishable from "this object is legitimately empty" —
|
|
559
|
+
* a green sync that quietly carries nothing.
|
|
560
|
+
*
|
|
561
|
+
* Two detectable defects, both provable from metadata alone:
|
|
562
|
+
* - a MULTI-var path (`/v3/sequences/{sequence_id}/contacts/{contact_id}/preview`) with only the
|
|
563
|
+
* single-valued `parentObjectName` — both vars would resolve to the same parent, which the base
|
|
564
|
+
* rejects as a dependency cycle;
|
|
565
|
+
* - a declared parent that is missing, or that declares NO primary key (e.g. an embedded projection
|
|
566
|
+
* such as `ContactCustomField`, whose fields are `key`/`value`) — there is no id column to iterate.
|
|
567
|
+
*/
|
|
568
|
+
parentDeclarationWarning(obj) {
|
|
569
|
+
const vars = obj.APIPath.match(/\{\w+\}/g);
|
|
570
|
+
if (!vars || vars.length === 0)
|
|
571
|
+
return null;
|
|
572
|
+
if (vars.length > 1 && !this.readConfigString(obj, 'parentObjectNames')) {
|
|
573
|
+
const hasMap = this.hasConfigObject(obj, 'parentObjectNames');
|
|
574
|
+
if (!hasMap) {
|
|
575
|
+
return {
|
|
576
|
+
Code: 'PARENT_DECLARATION_INCOMPLETE',
|
|
577
|
+
Message: `"${obj.Name}": APIPath "${obj.APIPath}" has ${vars.length} template variables but the ` +
|
|
578
|
+
`metadata declares only the single-valued Configuration.parentObjectName. Each variable ` +
|
|
579
|
+
`needs its own entry in Configuration.parentObjectNames; without it every variable resolves ` +
|
|
580
|
+
`to the same parent and the fetch yields nothing. This is a METADATA gap, not a ` +
|
|
581
|
+
`connector defect — re-run the extractor for this object.`,
|
|
582
|
+
Data: { object: obj.Name, apiPath: obj.APIPath, templateVars: vars },
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const parentName = this.readConfigString(obj, 'parentObjectName');
|
|
587
|
+
if (!parentName)
|
|
588
|
+
return null;
|
|
589
|
+
const parent = this.tryGetCachedObject(parentName);
|
|
590
|
+
if (!parent) {
|
|
591
|
+
return {
|
|
592
|
+
Code: 'PARENT_DECLARATION_INVALID',
|
|
593
|
+
Message: `"${obj.Name}": Configuration.parentObjectName="${parentName}" names no active ` +
|
|
594
|
+
`IntegrationObject, so the {${vars[0].slice(1, -1)}} variable cannot be iterated. ` +
|
|
595
|
+
`METADATA gap — re-run the extractor for this object.`,
|
|
596
|
+
Data: { object: obj.Name, declaredParent: parentName },
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const parentHasPK = this.GetCachedFields(parent.ID).some(f => f.IsPrimaryKey);
|
|
600
|
+
if (!parentHasPK) {
|
|
601
|
+
return {
|
|
602
|
+
Code: 'PARENT_DECLARATION_INVALID',
|
|
603
|
+
Message: `"${obj.Name}": Configuration.parentObjectName="${parentName}" resolves to an object that ` +
|
|
604
|
+
`declares NO primary key, so it can supply no ids for {${vars[0].slice(1, -1)}} and this ` +
|
|
605
|
+
`object will fetch nothing. METADATA gap — re-run the extractor for this object.`,
|
|
606
|
+
Data: { object: obj.Name, declaredParent: parentName },
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
/** True when the IO's Configuration carries KEY as a non-empty object (used for the per-var parent map). */
|
|
612
|
+
hasConfigObject(obj, key) {
|
|
613
|
+
if (!obj.Configuration)
|
|
614
|
+
return false;
|
|
615
|
+
try {
|
|
616
|
+
const cfg = JSON.parse(obj.Configuration);
|
|
617
|
+
const v = cfg[key];
|
|
618
|
+
return !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// ── CRUD ─────────────────────────────────────────────────────────
|
|
625
|
+
//
|
|
626
|
+
// The per-operation metadata columns (Create*/Update*/Delete*) drive all three verbs — this class does
|
|
627
|
+
// NOT re-implement per-object write logic. The three overrides below exist for exactly two documented
|
|
628
|
+
// vendor idiosyncrasies that the generic dispatch cannot express, and they route back through the base's
|
|
629
|
+
// own BuildOperationBody / ExtractIDFromResponse / BuildCreatedResult helpers:
|
|
630
|
+
//
|
|
631
|
+
// (a) NON-ATOMIC PARTIAL SUCCESS. Reply.io answers its non-atomic write endpoints with HTTP 200 and a
|
|
632
|
+
// dictionary of ONLY the failed items. Trusting the status code (or a `Status === 'Success'` field,
|
|
633
|
+
// which this vendor does not even emit) reports a silent failure as a success. Every write outcome
|
|
634
|
+
// is therefore asserted from the per-item dictionary.
|
|
635
|
+
// (b) NAMED / NESTED PATH VARS. Write paths carry parent segments (`/v3/sequences/{id}/steps/{step_id}`,
|
|
636
|
+
// `/v3/ai-sdr/knowledge-bases/{knowledge_base_id}/documents/{document_id}`); the base substitutes
|
|
637
|
+
// only `{id}`. See the SubstituteIDInPath override.
|
|
638
|
+
//
|
|
639
|
+
// NO write is ever auto-retried here. A send / enrollment / approval / start that times out may well have
|
|
640
|
+
// taken effect, so a blind retry can double-send; recovery must begin with a verifying READ (GetRecord),
|
|
641
|
+
// which is the engine's decision, not this connector's.
|
|
642
|
+
/** Create — generic per-operation dispatch + parent-var substitution + partial-success assertion. */
|
|
643
|
+
async CreateRecord(ctx) {
|
|
644
|
+
const ci = ctx.CompanyIntegration;
|
|
645
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
646
|
+
if (!obj.CreateAPIPath || !obj.CreateMethod)
|
|
647
|
+
return super.CreateRecord(ctx);
|
|
648
|
+
const path = this.substitutePathVarsFromAttributes(obj.CreateAPIPath, ctx.Attributes);
|
|
649
|
+
const body = this.BuildOperationBody(ctx.Attributes, obj.CreateBodyShape, obj.CreateBodyKey);
|
|
650
|
+
const response = await this.executeWrite(ci, ctx.ContextUser, path, obj.CreateMethod, body);
|
|
651
|
+
const failure = this.assertWriteOutcome(response, 'create', ctx.ObjectName);
|
|
652
|
+
if (failure)
|
|
653
|
+
return failure;
|
|
654
|
+
return this.BuildCreatedResult(this.ExtractIDFromResponse(response, obj.CreateIDLocation), response.Status, ctx.ObjectName);
|
|
655
|
+
}
|
|
656
|
+
/** Update — generic per-operation dispatch (PUT or PATCH per IO) + partial-success assertion. */
|
|
657
|
+
async UpdateRecord(ctx) {
|
|
658
|
+
const ci = ctx.CompanyIntegration;
|
|
659
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
660
|
+
if (!obj.UpdateAPIPath || !obj.UpdateMethod)
|
|
661
|
+
return super.UpdateRecord(ctx);
|
|
662
|
+
const path = this.SubstituteIDInPath(obj.UpdateAPIPath, ctx.ExternalID, obj.UpdateIDLocation);
|
|
663
|
+
const body = this.BuildOperationBody(ctx.Attributes, obj.UpdateBodyShape, obj.UpdateBodyKey);
|
|
664
|
+
const response = await this.executeWrite(ci, ctx.ContextUser, path, obj.UpdateMethod, body);
|
|
665
|
+
const failure = this.assertWriteOutcome(response, 'update', ctx.ObjectName);
|
|
666
|
+
if (failure)
|
|
667
|
+
return failure;
|
|
668
|
+
return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
|
|
669
|
+
}
|
|
670
|
+
/** Delete — generic per-operation dispatch (hard delete; the vendor has no universal tombstone). */
|
|
671
|
+
async DeleteRecord(ctx) {
|
|
672
|
+
const ci = ctx.CompanyIntegration;
|
|
673
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
674
|
+
if (!obj.DeleteAPIPath || !obj.DeleteMethod)
|
|
675
|
+
return super.DeleteRecord(ctx);
|
|
676
|
+
const path = this.SubstituteIDInPath(obj.DeleteAPIPath, ctx.ExternalID, obj.DeleteIDLocation);
|
|
677
|
+
const response = await this.executeWrite(ci, ctx.ContextUser, path, obj.DeleteMethod, undefined);
|
|
678
|
+
const failure = this.assertWriteOutcome(response, 'delete', ctx.ObjectName);
|
|
679
|
+
if (failure)
|
|
680
|
+
return failure;
|
|
681
|
+
return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
|
|
682
|
+
}
|
|
683
|
+
/** Authenticates, resolves the URL and fires ONE write request. No retry — see the CRUD section note. */
|
|
684
|
+
async executeWrite(companyIntegration, contextUser, path, method, body) {
|
|
685
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
686
|
+
const baseURL = this.GetBaseURL(companyIntegration, auth);
|
|
687
|
+
const headers = this.BuildHeaders(auth);
|
|
688
|
+
return this.MakeHTTPRequest(auth, this.joinURL(baseURL, path), method, headers, body);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Asserts a write actually happened. Returns a failure CRUDResult, or null when the write succeeded.
|
|
692
|
+
*
|
|
693
|
+
* Order matters: a non-2xx is a failure classified off the problem CODE; a 2xx is only a success once the
|
|
694
|
+
* body has been checked for the non-atomic per-item failure dictionary. HTTP 200 alone proves the request
|
|
695
|
+
* was ACCEPTED, not that the item was PROCESSED — the two are different on this vendor.
|
|
696
|
+
*/
|
|
697
|
+
assertWriteOutcome(response, verb, objectName) {
|
|
698
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
699
|
+
const problem = this.parseProblem(response.Body);
|
|
700
|
+
return {
|
|
701
|
+
Success: false,
|
|
702
|
+
StatusCode: response.Status,
|
|
703
|
+
ErrorMessage: this.describeProblem(response.Status, problem, `${verb} ${objectName}`),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const notProcessed = this.parseNotProcessed(response.Body);
|
|
707
|
+
if (notProcessed && notProcessed.size > 0) {
|
|
708
|
+
const detail = [...notProcessed.entries()]
|
|
709
|
+
.map(([id, item]) => `${id}: ${item.error ?? 'unknown'}${item.errorDetails ? ` (${item.errorDetails})` : ''}`)
|
|
710
|
+
.join('; ');
|
|
711
|
+
return {
|
|
712
|
+
Success: false,
|
|
713
|
+
StatusCode: response.Status,
|
|
714
|
+
ErrorMessage: `Reply.io ${verb} on "${objectName}" returned HTTP ${response.Status} but the non-atomic result ` +
|
|
715
|
+
`reported ${notProcessed.size} item(s) NOT PROCESSED — ${detail}. ` +
|
|
716
|
+
`Do NOT retry blindly: verify with a read first.`,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Parses a non-atomic (bulk) response body: a dictionary keyed by item id whose values are
|
|
723
|
+
* `{ error, errorDetails }` — ONLY failed items appear, and `{}` means everything succeeded. Returns null
|
|
724
|
+
* when the body is not that shape (an ordinary single-record response), so this check costs nothing on
|
|
725
|
+
* the normal path and can never misread a created record as a failure.
|
|
726
|
+
*/
|
|
727
|
+
parseNotProcessed(body) {
|
|
728
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
729
|
+
return null;
|
|
730
|
+
const entries = Object.entries(body);
|
|
731
|
+
if (entries.length === 0)
|
|
732
|
+
return new Map(); // `{}` — the documented all-succeeded signal
|
|
733
|
+
const out = new Map();
|
|
734
|
+
for (const [key, value] of entries) {
|
|
735
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
736
|
+
return null;
|
|
737
|
+
const v = value;
|
|
738
|
+
if (typeof v.error !== 'string')
|
|
739
|
+
return null; // not a NotProcessedItemResult → not a bulk body
|
|
740
|
+
out.set(key, {
|
|
741
|
+
error: v.error,
|
|
742
|
+
errorDetails: typeof v.errorDetails === 'string' ? v.errorDetails : null,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return out;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* OVERRIDDEN so the generic Update/Delete/Get path can template Reply's NAMED and NESTED path vars
|
|
749
|
+
* (`{step_id}`, `{variant_id}`, `{knowledge_base_id}`, `{document_id}`, `{tagId}`) — the base substitutes
|
|
750
|
+
* only `{id}`/`{ExternalID}`. A single-var path takes the whole ExternalID; a multi-var (nested) path
|
|
751
|
+
* takes the composite `parent|child` ExternalID split in path order.
|
|
752
|
+
*/
|
|
753
|
+
SubstituteIDInPath(path, externalID, idLocation) {
|
|
754
|
+
if (idLocation && idLocation !== 'path')
|
|
755
|
+
return path;
|
|
756
|
+
const vars = path.match(/\{\w+\}/g);
|
|
757
|
+
if (!vars || vars.length === 0)
|
|
758
|
+
return path;
|
|
759
|
+
if (vars.length === 1)
|
|
760
|
+
return path.replace(vars[0], encodeURIComponent(externalID));
|
|
761
|
+
const parts = externalID.split('|');
|
|
762
|
+
let out = path;
|
|
763
|
+
vars.forEach((v, i) => {
|
|
764
|
+
const val = parts[i] ?? parts[parts.length - 1] ?? externalID;
|
|
765
|
+
out = out.replace(v, encodeURIComponent(val));
|
|
766
|
+
});
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
/** Fills a create path's PARENT template vars from the record's own attributes (nested creates). */
|
|
770
|
+
substitutePathVarsFromAttributes(path, attributes) {
|
|
771
|
+
return path.replace(/\{(\w+)\}/g, (match, name) => {
|
|
772
|
+
const v = attributes[name];
|
|
773
|
+
return v != null && String(v).length > 0 ? encodeURIComponent(String(v)) : match;
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
// ── Error classification (RFC 9457, code-driven) ─────────────────
|
|
777
|
+
/**
|
|
778
|
+
* OVERRIDDEN for the RFC 9457 `application/problem+json` envelope. Never assumes a body exists — a
|
|
779
|
+
* Reply.io 401 carries none. The message leads with the STABLE machine `code` so the engine's classifier
|
|
780
|
+
* and any human reader both key off the slug rather than the localized `detail`.
|
|
781
|
+
*/
|
|
782
|
+
ExtractErrorMessage(response) {
|
|
783
|
+
const problem = this.parseProblem(response.Body);
|
|
784
|
+
if (!problem && response.Status < 400)
|
|
785
|
+
return undefined;
|
|
786
|
+
return this.describeProblem(response.Status, problem, undefined);
|
|
787
|
+
}
|
|
788
|
+
/** Parses an RFC 9457 problem body; null for an empty/non-problem payload (401 has NO body). */
|
|
789
|
+
parseProblem(body) {
|
|
790
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
791
|
+
return null;
|
|
792
|
+
const b = body;
|
|
793
|
+
// A problem document is identified by its STABLE machine slug, or by the RFC's status+title/detail
|
|
794
|
+
// pair. `title` alone is NOT sufficient — ordinary Reply records carry a `title` field (a contact's
|
|
795
|
+
// job title), and treating one as a problem would misclassify a perfectly good record.
|
|
796
|
+
const hasCode = typeof b.code === 'string';
|
|
797
|
+
const hasStatusPair = typeof b.status === 'number' && (typeof b.title === 'string' || typeof b.detail === 'string');
|
|
798
|
+
if (!hasCode && !hasStatusPair)
|
|
799
|
+
return null;
|
|
800
|
+
return {
|
|
801
|
+
title: typeof b.title === 'string' ? b.title : undefined,
|
|
802
|
+
status: typeof b.status === 'number' ? b.status : undefined,
|
|
803
|
+
detail: typeof b.detail === 'string' ? b.detail : undefined,
|
|
804
|
+
code: typeof b.code === 'string' ? b.code : undefined,
|
|
805
|
+
errors: b.errors,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Maps a Reply.io response to a `SyncErrorCode` using the STABLE machine slug first and the HTTP status
|
|
810
|
+
* as the fallback — never the human-readable `detail`, which is localized prose and free to change.
|
|
811
|
+
*
|
|
812
|
+
* 403 maps to CONFIGURATION_ERROR (an entitlement/scope gap the operator resolves), deliberately NOT to
|
|
813
|
+
* an auth failure: the credential is valid, the tenant simply is not entitled to that surface.
|
|
814
|
+
*/
|
|
815
|
+
ClassifyProblem(status, problem) {
|
|
816
|
+
const variant = problem?.code?.split('.')[1]?.toLowerCase();
|
|
817
|
+
if (variant) {
|
|
818
|
+
if (variant === 'invalidpagination' || variant === 'validationfailed' || variant === 'invalidinput') {
|
|
819
|
+
return 'VALIDATION_ERROR';
|
|
820
|
+
}
|
|
821
|
+
if (variant === 'forbidden' || variant === 'featurenotavailable' || variant === 'readonly') {
|
|
822
|
+
return 'CONFIGURATION_ERROR';
|
|
823
|
+
}
|
|
824
|
+
if (variant === 'duplicatename' || variant === 'alreadyexists')
|
|
825
|
+
return 'DUPLICATE_KEY';
|
|
826
|
+
}
|
|
827
|
+
if (status === 429)
|
|
828
|
+
return 'RATE_LIMIT_EXCEEDED';
|
|
829
|
+
if (status === 401)
|
|
830
|
+
return 'CONFIGURATION_ERROR';
|
|
831
|
+
if (status === 403)
|
|
832
|
+
return 'CONFIGURATION_ERROR';
|
|
833
|
+
if (status === 400 || status === 422)
|
|
834
|
+
return 'VALIDATION_ERROR';
|
|
835
|
+
if (status === 409)
|
|
836
|
+
return 'DUPLICATE_KEY';
|
|
837
|
+
if (status === 408 || status === 504)
|
|
838
|
+
return 'NETWORK_TIMEOUT';
|
|
839
|
+
if (status >= 500)
|
|
840
|
+
return 'CONNECTOR_ERROR';
|
|
841
|
+
return 'CONNECTOR_ERROR';
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Renders a problem into a message that (a) leads with the machine slug, (b) states the classification,
|
|
845
|
+
* and (c) carries wording the engine's message-based `ClassifyError` maps to the SAME SyncErrorCode —
|
|
846
|
+
* so the code the connector determined survives the string boundary instead of being re-guessed.
|
|
847
|
+
*/
|
|
848
|
+
describeProblem(status, problem, context) {
|
|
849
|
+
const code = this.ClassifyProblem(status, problem);
|
|
850
|
+
const hint = this.classifierHint(code);
|
|
851
|
+
const where = context ? ` on ${context}` : '';
|
|
852
|
+
const slug = problem?.code ? `code=${problem.code}` : 'code=(none — middleware problem)';
|
|
853
|
+
const detail = problem?.detail ?? (status === 401 ? 'empty body (RFC 9457 401 carries none)' : 'no detail');
|
|
854
|
+
const entitlement = status === 403
|
|
855
|
+
? ' NOTE: 403 means REACHABLE BUT NOT ENTITLED (missing scope / plan feature) — the credential is valid.'
|
|
856
|
+
: '';
|
|
857
|
+
return `Reply.io HTTP ${status}${where}: ${slug}; ${hint}; detail="${detail}".${entitlement}`;
|
|
858
|
+
}
|
|
859
|
+
/** A phrase the engine's message-based ClassifyError maps to the given code (keeps both paths in sync). */
|
|
860
|
+
classifierHint(code) {
|
|
861
|
+
switch (code) {
|
|
862
|
+
case 'RATE_LIMIT_EXCEEDED': return 'rate limit exceeded';
|
|
863
|
+
case 'VALIDATION_ERROR': return 'validation failure';
|
|
864
|
+
case 'DUPLICATE_KEY': return 'duplicate key';
|
|
865
|
+
case 'NETWORK_TIMEOUT': return 'request timeout';
|
|
866
|
+
case 'CONFIGURATION_ERROR': return 'configuration/entitlement problem';
|
|
867
|
+
default: return 'connector error';
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Records the last non-2xx seen on the wire so an empty fetch can be explained rather than guessed at,
|
|
872
|
+
* and captures a 429's `Retry-After` before the inherited validator throws it away (see
|
|
873
|
+
* {@link pendingRetryAfterMs}).
|
|
874
|
+
*/
|
|
875
|
+
recordProblem(response, url) {
|
|
876
|
+
if (response.Status >= 200 && response.Status < 300)
|
|
877
|
+
return;
|
|
878
|
+
this.lastProblem = { Status: response.Status, URL: url, Problem: this.parseProblem(response.Body) };
|
|
879
|
+
if (response.Status === 429) {
|
|
880
|
+
this.pendingRetryAfterMs = this.retryAfterFromHeaders(response.Headers) ?? null;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
// ── Connection test ──────────────────────────────────────────────
|
|
884
|
+
/**
|
|
885
|
+
* Verifies the credential against `GET /v3/whoami` — documented `x-required-scope: none`, so ANY valid
|
|
886
|
+
* key can call it and a failure there is unambiguous. 401 = bad/missing key (empty body by design);
|
|
887
|
+
* 403 on this endpoint would still mean the key is valid but the account is restricted, so it is reported
|
|
888
|
+
* as a NON-credential problem.
|
|
889
|
+
*/
|
|
890
|
+
async TestConnection(companyIntegration, contextUser) {
|
|
891
|
+
try {
|
|
892
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
893
|
+
const baseURL = this.GetBaseURL(companyIntegration, auth);
|
|
894
|
+
const headers = this.BuildHeaders(auth);
|
|
895
|
+
const response = await this.MakeHTTPRequest(auth, `${baseURL}${REPLY_WHOAMI_PATH}`, 'GET', headers);
|
|
896
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
897
|
+
const body = (response.Body && typeof response.Body === 'object')
|
|
898
|
+
? response.Body
|
|
899
|
+
: {};
|
|
900
|
+
const who = body.username != null ? ` (user ${String(body.username)}, team ${String(body.teamId ?? '?')})` : '';
|
|
901
|
+
return { Success: true, Message: `Reply.io connection successful${who}.` };
|
|
902
|
+
}
|
|
903
|
+
if (response.Status === 401) {
|
|
904
|
+
return {
|
|
905
|
+
Success: false,
|
|
906
|
+
Message: 'Reply.io authentication failed (HTTP 401) — the API key is missing, invalid or revoked. ' +
|
|
907
|
+
'The vendor returns an empty body on 401; check the key value itself.',
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
if (response.Status === 403) {
|
|
911
|
+
return {
|
|
912
|
+
Success: false,
|
|
913
|
+
Message: 'Reply.io returned HTTP 403 on /v3/whoami — the key is VALID but the account is not ' +
|
|
914
|
+
'entitled to this call. This is an entitlement/plan problem, not a bad credential.',
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
Success: false,
|
|
919
|
+
Message: this.describeProblem(response.Status, this.parseProblem(response.Body), 'TestConnection'),
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
924
|
+
return { Success: false, Message: `Reply.io connection test error: ${msg}` };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// ── Credential loading ───────────────────────────────────────────
|
|
928
|
+
/** Reads the API key from the linked Credential entity, falling back to the Configuration JSON. */
|
|
929
|
+
async loadCredentials(companyIntegration, contextUser) {
|
|
930
|
+
let creds = null;
|
|
931
|
+
if (companyIntegration.CredentialID) {
|
|
932
|
+
creds = await this.loadFromCredentialEntity(companyIntegration.CredentialID, contextUser);
|
|
933
|
+
}
|
|
934
|
+
const configCreds = companyIntegration.Configuration
|
|
935
|
+
? this.parseCredentialJson(companyIntegration.Configuration)
|
|
936
|
+
: null;
|
|
937
|
+
if (!creds && !configCreds) {
|
|
938
|
+
throw new Error('No Reply.io credential found. Attach a credential of type "API Key" (field `apiKey`), ' +
|
|
939
|
+
'or set the connection Configuration key "ApiKey".');
|
|
940
|
+
}
|
|
941
|
+
return { ApiKey: creds?.ApiKey ?? configCreds?.ApiKey };
|
|
942
|
+
}
|
|
943
|
+
/** Loads a credential row and parses its Values JSON. */
|
|
944
|
+
async loadFromCredentialEntity(credentialID, contextUser, provider) {
|
|
945
|
+
const md = provider ?? new Metadata();
|
|
946
|
+
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
947
|
+
const loaded = await credential.Load(credentialID);
|
|
948
|
+
if (!loaded || !credential.Values)
|
|
949
|
+
return null;
|
|
950
|
+
return this.parseCredentialJson(credential.Values);
|
|
951
|
+
}
|
|
952
|
+
/** Extracts the API key from a credential/config JSON string (tolerant of absent/invalid). */
|
|
953
|
+
parseCredentialJson(json) {
|
|
954
|
+
try {
|
|
955
|
+
const parsed = JSON.parse(json);
|
|
956
|
+
return { ApiKey: this.firstString(parsed, ['apiKey', 'ApiKey', 'apikey', 'api_key', 'token', 'Token']) };
|
|
957
|
+
}
|
|
958
|
+
catch {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Reads an explicit base-URL override from the connection Configuration. Only an absolute http(s) URL is
|
|
964
|
+
* honored, so a stray value cannot misroute a production tenant. Null (the normal case) → the vendor host.
|
|
965
|
+
*/
|
|
966
|
+
resolveBaseURLOverride(companyIntegration) {
|
|
967
|
+
if (!companyIntegration.Configuration)
|
|
968
|
+
return null;
|
|
969
|
+
let parsed;
|
|
970
|
+
try {
|
|
971
|
+
parsed = JSON.parse(companyIntegration.Configuration);
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
const raw = this.firstString(parsed, ['BaseURL', 'BaseUrl', 'baseURL', 'baseUrl', 'APIBaseURL', 'ApiBaseURL', 'apiBaseUrl']);
|
|
977
|
+
return raw && /^https?:\/\//i.test(raw.trim()) ? raw.trim() : null;
|
|
978
|
+
}
|
|
979
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
980
|
+
/** Joins a base URL and a path (mirrors the base's private BuildFullURL). */
|
|
981
|
+
joinURL(baseURL, apiPath) {
|
|
982
|
+
const base = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
|
|
983
|
+
const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
|
|
984
|
+
return `${base}${path}`;
|
|
985
|
+
}
|
|
986
|
+
/** PK field names in declared Sequence order; empty when the object declares none. */
|
|
987
|
+
pkFieldNames(fields) {
|
|
988
|
+
return fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence).map(f => f.Name);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Builds an ExternalRecord whose Fields carry the FULL source record (full-record pass-through — the
|
|
992
|
+
* framework's custom-column capture diffs keys(Fields) against the active field maps, so a narrowed
|
|
993
|
+
* literal would make custom columns permanently invisible). ExternalID is the composite of the declared
|
|
994
|
+
* PK fields when all are present, else the vendor's universal `id`, else empty.
|
|
995
|
+
*/
|
|
996
|
+
buildExternalRecord(raw, objectType, pkFieldNames) {
|
|
997
|
+
const allPresent = pkFieldNames.length > 0 && pkFieldNames.every(n => raw[n] != null && String(raw[n]).length > 0);
|
|
998
|
+
const externalID = allPresent
|
|
999
|
+
? pkFieldNames.map(n => String(raw[n])).join('|')
|
|
1000
|
+
: (raw.id != null ? String(raw.id) : '');
|
|
1001
|
+
return { ExternalID: externalID, ObjectType: objectType, Fields: raw };
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Gets an IO by NAME alone from the engine cache, without throwing (callers may run before the cache is
|
|
1005
|
+
* warm, and StableOrderingKey has no IntegrationID to hand). Protected so a test/mock subclass can
|
|
1006
|
+
* substitute fixture rows exactly as it does for GetCachedObject.
|
|
1007
|
+
*/
|
|
1008
|
+
tryGetCachedObject(objectName) {
|
|
1009
|
+
try {
|
|
1010
|
+
const integ = IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName);
|
|
1011
|
+
if (!integ)
|
|
1012
|
+
return null;
|
|
1013
|
+
return IntegrationEngineBase.Instance.GetIntegrationObject(integ.ID, objectName) ?? null;
|
|
1014
|
+
}
|
|
1015
|
+
catch {
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/** Reads a trimmed string value from an IntegrationObject's Configuration JSON (tolerant of absent/invalid). */
|
|
1020
|
+
readConfigString(obj, key) {
|
|
1021
|
+
const raw = obj.Configuration;
|
|
1022
|
+
if (!raw)
|
|
1023
|
+
return null;
|
|
1024
|
+
try {
|
|
1025
|
+
const cfg = JSON.parse(raw);
|
|
1026
|
+
const v = cfg[key];
|
|
1027
|
+
return typeof v === 'string' && v.trim().length > 0 ? v.trim() : null;
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/** Returns the first present, non-empty string value among the given keys. */
|
|
1034
|
+
firstString(obj, keys) {
|
|
1035
|
+
for (const k of keys) {
|
|
1036
|
+
const v = obj[k];
|
|
1037
|
+
if (typeof v === 'string' && v.length > 0)
|
|
1038
|
+
return v;
|
|
1039
|
+
}
|
|
1040
|
+
return undefined;
|
|
1041
|
+
}
|
|
1042
|
+
/** Best-effort extraction of response headers from an error object (for ExtractRetryAfterMs). */
|
|
1043
|
+
extractHeadersFromError(error) {
|
|
1044
|
+
if (!error || typeof error !== 'object')
|
|
1045
|
+
return undefined;
|
|
1046
|
+
const e = error;
|
|
1047
|
+
const resp = e.response ?? e;
|
|
1048
|
+
const headers = resp.headers ?? resp.Headers;
|
|
1049
|
+
if (headers && typeof headers === 'object')
|
|
1050
|
+
return headers;
|
|
1051
|
+
return undefined;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
ReplyConnector = __decorate([
|
|
1055
|
+
RegisterClass(BaseIntegrationConnector, 'ReplyConnector')
|
|
1056
|
+
], ReplyConnector);
|
|
1057
|
+
export { ReplyConnector };
|
|
1058
|
+
//# sourceMappingURL=ReplyConnector.js.map
|