@memberjunction/integration-connectors 5.41.0 → 5.42.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/CventConnector.d.ts +146 -0
- package/dist/CventConnector.d.ts.map +1 -0
- package/dist/CventConnector.js +540 -0
- package/dist/CventConnector.js.map +1 -0
- package/dist/DynamicsDataverseConnector.d.ts +296 -0
- package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
- package/dist/DynamicsDataverseConnector.js +790 -0
- package/dist/DynamicsDataverseConnector.js.map +1 -0
- package/dist/FontevaConnector.d.ts +153 -0
- package/dist/FontevaConnector.d.ts.map +1 -0
- package/dist/FontevaConnector.js +421 -0
- package/dist/FontevaConnector.js.map +1 -0
- package/dist/GrowthZoneConnector.d.ts +123 -125
- package/dist/GrowthZoneConnector.d.ts.map +1 -1
- package/dist/GrowthZoneConnector.js +456 -499
- package/dist/GrowthZoneConnector.js.map +1 -1
- package/dist/HivebriteConnector.d.ts +151 -0
- package/dist/HivebriteConnector.d.ts.map +1 -0
- package/dist/HivebriteConnector.js +533 -0
- package/dist/HivebriteConnector.js.map +1 -0
- package/dist/IMISConnector.d.ts +112 -96
- package/dist/IMISConnector.d.ts.map +1 -1
- package/dist/IMISConnector.js +430 -417
- package/dist/IMISConnector.js.map +1 -1
- package/dist/MemberSuiteConnector.d.ts +185 -0
- package/dist/MemberSuiteConnector.d.ts.map +1 -0
- package/dist/MemberSuiteConnector.js +762 -0
- package/dist/MemberSuiteConnector.js.map +1 -0
- package/dist/NeonCRMConnector.d.ts +273 -0
- package/dist/NeonCRMConnector.d.ts.map +1 -0
- package/dist/NeonCRMConnector.js +1039 -0
- package/dist/NeonCRMConnector.js.map +1 -0
- package/dist/NetForumConnector.d.ts +140 -47
- package/dist/NetForumConnector.d.ts.map +1 -1
- package/dist/NetForumConnector.js +668 -395
- package/dist/NetForumConnector.js.map +1 -1
- package/dist/NetSuiteConnector.d.ts +116 -50
- package/dist/NetSuiteConnector.d.ts.map +1 -1
- package/dist/NetSuiteConnector.js +608 -510
- package/dist/NetSuiteConnector.js.map +1 -1
- package/dist/NimbleAMSConnector.d.ts +104 -11
- package/dist/NimbleAMSConnector.d.ts.map +1 -1
- package/dist/NimbleAMSConnector.js +552 -258
- package/dist/NimbleAMSConnector.js.map +1 -1
- package/dist/NoviConnector.d.ts +178 -0
- package/dist/NoviConnector.d.ts.map +1 -0
- package/dist/NoviConnector.js +566 -0
- package/dist/NoviConnector.js.map +1 -0
- package/dist/ORCIDConnector.d.ts +207 -0
- package/dist/ORCIDConnector.d.ts.map +1 -0
- package/dist/ORCIDConnector.js +708 -0
- package/dist/ORCIDConnector.js.map +1 -0
- package/dist/OpenWaterConnector.d.ts +173 -0
- package/dist/OpenWaterConnector.d.ts.map +1 -0
- package/dist/OpenWaterConnector.js +893 -0
- package/dist/OpenWaterConnector.js.map +1 -0
- package/dist/PathLMSConnector.d.ts +395 -0
- package/dist/PathLMSConnector.d.ts.map +1 -0
- package/dist/PathLMSConnector.js +1281 -0
- package/dist/PathLMSConnector.js.map +1 -0
- package/dist/PheedLoopConnector.d.ts +119 -0
- package/dist/PheedLoopConnector.d.ts.map +1 -0
- package/dist/PheedLoopConnector.js +429 -0
- package/dist/PheedLoopConnector.js.map +1 -0
- package/dist/PropFuelConnector.d.ts +135 -61
- package/dist/PropFuelConnector.d.ts.map +1 -1
- package/dist/PropFuelConnector.js +366 -467
- package/dist/PropFuelConnector.js.map +1 -1
- package/dist/RhythmConnector.d.ts +188 -0
- package/dist/RhythmConnector.d.ts.map +1 -0
- package/dist/RhythmConnector.js +784 -0
- package/dist/RhythmConnector.js.map +1 -0
- package/dist/SalesforceConnector.d.ts +69 -3
- package/dist/SalesforceConnector.d.ts.map +1 -1
- package/dist/SalesforceConnector.js +188 -166
- package/dist/SalesforceConnector.js.map +1 -1
- package/dist/SharePointConnector.d.ts +37 -30
- package/dist/SharePointConnector.d.ts.map +1 -1
- package/dist/SharePointConnector.js +163 -243
- package/dist/SharePointConnector.js.map +1 -1
- package/dist/YourMembershipConnector.d.ts.map +1 -1
- package/dist/YourMembershipConnector.js +13 -5
- package/dist/YourMembershipConnector.js.map +1 -1
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/dist/BettyConnector.d.ts +0 -288
- package/dist/BettyConnector.d.ts.map +0 -1
- package/dist/BettyConnector.js +0 -909
- package/dist/BettyConnector.js.map +0 -1
package/dist/IMISConnector.js
CHANGED
|
@@ -4,15 +4,66 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
4
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
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
|
+
/**
|
|
8
|
+
* IMISConnector — Integration connector for iMIS EMS (Advanced Solutions International)
|
|
9
|
+
* REST API.
|
|
10
|
+
*
|
|
11
|
+
* ── Auth: OAuth 2.0 PASSWORD grant (resource-owner) ─────────────────────────
|
|
12
|
+
* Mints/refreshes a bearer ACCESS TOKEN via the shared {@link OAuth2TokenManager}
|
|
13
|
+
* (no inlined token/crypto logic). iMIS issues tokens via the resource-owner
|
|
14
|
+
* `grant_type=password` flow — username + password POSTed to the token endpoint:
|
|
15
|
+
* - default = `{BaseURL}/token`
|
|
16
|
+
* - override = the credential's `TokenURL` (when supplied)
|
|
17
|
+
* - fallback = the legacy `{BaseURL}/asiScheduler/token` path (older iMIS builds)
|
|
18
|
+
* iMIS does NOT require client_id/client_secret for the password grant, so the
|
|
19
|
+
* OAuth2TokenManager request is configured with empty client credentials in the
|
|
20
|
+
* body. Every API request sends `Authorization: Bearer {accessToken}`.
|
|
21
|
+
*
|
|
22
|
+
* ── Base URL ────────────────────────────────────────────────────────────────
|
|
23
|
+
* Comes from the credential's `BaseURL` (the tenant's iMIS API endpoint) — never
|
|
24
|
+
* a hardcoded host. Credential bytes are resolved at runtime, never at build.
|
|
25
|
+
*
|
|
26
|
+
* ── Catalog (metadata-driven, NOT hardcoded) ───────────────────────────────
|
|
27
|
+
* The Declared core object/field set comes from the seeded metadata loaded into
|
|
28
|
+
* the IntegrationEngineBase cache. The connector NEVER bakes an object/field catalog
|
|
29
|
+
* into code. DiscoverObjects/DiscoverFields express only the MECHANISM: they call the
|
|
30
|
+
* auth-gated /EntityDefinition + /{entity}/metadata endpoints at runtime to enumerate
|
|
31
|
+
* the tenant's Business-Object catalog + per-object fields, ADDITIVE to the Declared
|
|
32
|
+
* baseline. DiscoveryIsAuthoritative stays false (default) — these scoped enumerations
|
|
33
|
+
* never deactivate Declared metadata on absence.
|
|
34
|
+
*
|
|
35
|
+
* ── Pagination & incremental ───────────────────────────────────────────────
|
|
36
|
+
* iMIS uses the PagedResult envelope (`{ Items, Offset, Limit, Count, HasNext }`) with
|
|
37
|
+
* capitalized `Offset`/`Limit` query params; ExtractPaginationInfo reads HasNext/Offset/Limit
|
|
38
|
+
* and advances Offset by the returned count until HasNext is false. NormalizeResponse
|
|
39
|
+
* unwraps the Items collection. Incremental sync is metadata-driven: an IO with
|
|
40
|
+
* SupportsIncrementalSync=true emits its `IncrementalWatermarkField` as a `gt:` filter.
|
|
41
|
+
*
|
|
42
|
+
* ── Write ───────────────────────────────────────────────────────────────────
|
|
43
|
+
* CRUD rides the generic BaseRESTIntegrationConnector per-operation path: Create/Update/Delete
|
|
44
|
+
* read the per-operation IO columns (CreateAPIPath/Method/BodyShape/BodyKey/IDLocation, Update*,
|
|
45
|
+
* Delete*), creates route through BuildCreatedResult (fail loudly on empty ID), and DeleteMethod
|
|
46
|
+
* is metadata-driven (not assumed DELETE). Capability flags follow the per-operation columns the
|
|
47
|
+
* metadata declares — no hardcoded `true`, no 501 stubs. Full-record pass-through (Fields=raw).
|
|
48
|
+
*
|
|
49
|
+
* Reference: https://developer.imis.com/docs/imis-rest-api-data-models-and-swagger-json-files
|
|
50
|
+
*/
|
|
7
51
|
import { RegisterClass } from '@memberjunction/global';
|
|
8
52
|
import { Metadata } from '@memberjunction/core';
|
|
9
|
-
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
10
|
-
|
|
53
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, OAuth2TokenManager, } from '@memberjunction/integration-engine';
|
|
54
|
+
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
55
|
+
// ─── Constants ───────────────────────────────────────────────────────
|
|
56
|
+
/** The canonical MJ: Integrations.Name — part of the three-way invariant. */
|
|
57
|
+
const INTEGRATION_NAME = 'iMIS';
|
|
58
|
+
/** Default token endpoint path appended to the base when no TokenURL override is supplied. */
|
|
59
|
+
const DEFAULT_TOKEN_PATH = '/token';
|
|
60
|
+
/** Legacy token endpoint path for older iMIS deployments (documented fallback). */
|
|
61
|
+
const LEGACY_TOKEN_PATH = '/asiScheduler/token';
|
|
11
62
|
const DEFAULT_MAX_RETRIES = 3;
|
|
12
63
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
13
64
|
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 200;
|
|
14
|
-
const
|
|
15
|
-
/** iMIS-to-
|
|
65
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
66
|
+
/** iMIS-to-generic type mapping used when merging live-discovered field metadata. */
|
|
16
67
|
const IMIS_TYPE_MAP = {
|
|
17
68
|
'string': 'string',
|
|
18
69
|
'datetime': 'datetime',
|
|
@@ -29,87 +80,141 @@ const IMIS_TYPE_MAP = {
|
|
|
29
80
|
'uuid': 'uuid',
|
|
30
81
|
'monetaryamountdata': 'decimal',
|
|
31
82
|
};
|
|
32
|
-
// ─── Connector
|
|
33
|
-
/**
|
|
34
|
-
* Connector for iMIS EMS (Advanced Solutions International) REST API.
|
|
35
|
-
*
|
|
36
|
-
* Extends BaseRESTIntegrationConnector so the generic metadata-driven fetch
|
|
37
|
-
* loop handles pagination, template variables, and record normalization.
|
|
38
|
-
* Adds iMIS-specific:
|
|
39
|
-
* - OAuth 2.0 Password Grant / SSO refresh-token authentication
|
|
40
|
-
* - /{entity}/metadata runtime field discovery (merged with static IntegrationObjectField metadata, IsCustom=true)
|
|
41
|
-
* - /BOEntityDefinition runtime object discovery (customer Business Objects, IsCustom=true)
|
|
42
|
-
* - PagedResult envelope with Offset/Limit + HasNext flag
|
|
43
|
-
* - UpdatedOn=gt:<iso-date> watermark filter for incremental sync
|
|
44
|
-
* - PUT (full replace) for updates with automatic read-modify-write merge
|
|
45
|
-
*
|
|
46
|
-
* Reference: https://developer.imis.com/docs/imis-rest-api-data-models-and-swagger-json-files
|
|
47
|
-
*/
|
|
83
|
+
// ─── Connector Implementation ────────────────────────────────────────
|
|
48
84
|
let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
49
85
|
constructor() {
|
|
50
86
|
super(...arguments);
|
|
51
|
-
|
|
87
|
+
/** Cached auth context for the current sync run. */
|
|
88
|
+
this.authCache = null;
|
|
89
|
+
/** Shared OAuth2 token manager — owns the token round-trip + cache. */
|
|
90
|
+
this.tokenManager = new OAuth2TokenManager();
|
|
91
|
+
/** Timestamp of the last API request, for client-side throttling. */
|
|
52
92
|
this.lastRequestTime = 0;
|
|
53
|
-
/** Current watermark for the in-flight FetchChanges — used by AppendDefaultQueryParams */
|
|
54
|
-
this.currentWatermark = null;
|
|
55
93
|
}
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
get
|
|
59
|
-
|
|
94
|
+
// Returns the EXACT MJ: Integrations.Name string LITERAL so the T1 ThreeWayName invariant can
|
|
95
|
+
// statically parse the getter's returned value from connector source.
|
|
96
|
+
get IntegrationName() { return 'iMIS'; }
|
|
97
|
+
// ── Capability getters: METADATA-DRIVEN (no hardcoded answer) ─────
|
|
98
|
+
//
|
|
99
|
+
// Write capability FOLLOWS the per-operation CRUD columns on the cached IntegrationObjects
|
|
100
|
+
// (Declared metadata). An object is create-capable when it declares both CreateAPIPath +
|
|
101
|
+
// CreateMethod; same for update/delete. With no write metadata authored the surface is
|
|
102
|
+
// read-only (returns false) — but the moment a writable object's per-operation columns are
|
|
103
|
+
// populated, the capability flips on and the base generic CRUD path executes it.
|
|
104
|
+
get SupportsCreate() {
|
|
105
|
+
return this.anyObjectDeclares(o => !!o.CreateAPIPath && !!o.CreateMethod);
|
|
106
|
+
}
|
|
107
|
+
get SupportsUpdate() {
|
|
108
|
+
return this.anyObjectDeclares(o => !!o.UpdateAPIPath && !!o.UpdateMethod);
|
|
109
|
+
}
|
|
110
|
+
get SupportsDelete() {
|
|
111
|
+
return this.anyObjectDeclares(o => !!o.DeleteAPIPath && !!o.DeleteMethod);
|
|
112
|
+
}
|
|
60
113
|
get SupportsSearch() { return false; }
|
|
61
114
|
get SupportsListing() { return false; }
|
|
62
|
-
|
|
63
|
-
|
|
115
|
+
/** True when any cached IntegrationObject satisfies the predicate. []→false when the engine
|
|
116
|
+
* cache is unavailable (e.g. capability probed before configuration) — fail-safe read-only. */
|
|
117
|
+
anyObjectDeclares(pred) {
|
|
118
|
+
const integration = IntegrationEngineBase.Instance.GetIntegrationByName(INTEGRATION_NAME);
|
|
119
|
+
if (!integration)
|
|
120
|
+
return false;
|
|
121
|
+
return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integration.ID).some(pred);
|
|
122
|
+
}
|
|
123
|
+
// ─── BaseRESTIntegrationConnector abstract methods ──────────────
|
|
124
|
+
/**
|
|
125
|
+
* OAuth 2.0 password-grant authentication. Mints/refreshes the access token via the shared
|
|
126
|
+
* {@link OAuth2TokenManager} (no inlined token/crypto logic). One token exchange per run is
|
|
127
|
+
* cached and reused across every request.
|
|
128
|
+
*/
|
|
64
129
|
async Authenticate(companyIntegration, contextUser) {
|
|
65
|
-
|
|
130
|
+
if (this.authCache)
|
|
131
|
+
return this.authCache;
|
|
132
|
+
const config = await this.ParseConfig(companyIntegration, contextUser);
|
|
133
|
+
const baseUrl = this.ResolveBaseUrl(config);
|
|
134
|
+
const token = await this.MintToken(config, baseUrl);
|
|
135
|
+
const auth = { Token: token, BaseUrl: baseUrl, Config: config };
|
|
136
|
+
this.authCache = auth;
|
|
137
|
+
return auth;
|
|
66
138
|
}
|
|
67
|
-
|
|
68
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Runs the password-grant token round-trip through OAuth2TokenManager. Tries the resolved
|
|
141
|
+
* token endpoint first, then the legacy `/asiScheduler/token` path on failure (older iMIS).
|
|
142
|
+
*/
|
|
143
|
+
async MintToken(config, baseUrl) {
|
|
144
|
+
const primaryURL = this.ResolveTokenURL(config, baseUrl);
|
|
145
|
+
const req = this.BuildTokenRequest(config, primaryURL);
|
|
146
|
+
try {
|
|
147
|
+
const token = await this.tokenManager.GetAccessToken(req, 'password');
|
|
148
|
+
return token.AccessToken;
|
|
149
|
+
}
|
|
150
|
+
catch (primaryErr) {
|
|
151
|
+
// Legacy fallback only when no explicit override was given and the legacy path differs.
|
|
152
|
+
const legacyURL = `${baseUrl}${LEGACY_TOKEN_PATH}`;
|
|
153
|
+
if (config.TokenURL || legacyURL === primaryURL)
|
|
154
|
+
throw primaryErr;
|
|
155
|
+
this.tokenManager.Reset();
|
|
156
|
+
const token = await this.tokenManager.GetAccessToken({ ...req, TokenURL: legacyURL }, 'password');
|
|
157
|
+
return token.AccessToken;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Builds the OAuth2 password-grant request. iMIS does not require client credentials. */
|
|
161
|
+
BuildTokenRequest(config, tokenURL) {
|
|
69
162
|
return {
|
|
70
|
-
|
|
71
|
-
|
|
163
|
+
TokenURL: tokenURL,
|
|
164
|
+
ClientId: config.ClientId ?? '',
|
|
165
|
+
ClientSecret: config.ClientSecret ?? '',
|
|
166
|
+
Username: config.Username,
|
|
167
|
+
Password: config.Password,
|
|
168
|
+
TimeoutMs: config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
72
169
|
};
|
|
73
170
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
imisAuth.Token = fresh.Token;
|
|
92
|
-
imisAuth.ExpiresAt = fresh.ExpiresAt;
|
|
93
|
-
effectiveHeaders['Authorization'] = `Bearer ${fresh.Token}`;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (this.ShouldRetry(response.status) && attempt < maxRetries) {
|
|
97
|
-
const delay = this.ComputeRetryDelay(response, attempt);
|
|
98
|
-
await this.Sleep(delay);
|
|
99
|
-
continue;
|
|
171
|
+
/**
|
|
172
|
+
* Extracts the new record's external ID from a create response. iMIS returns the created
|
|
173
|
+
* object with a vendor-specific ID field (Id / PartyId / EventId / InvoiceId / …); the base
|
|
174
|
+
* scanner only knows the generic `id` family, so widen it for iMIS's `{Object}Id` convention.
|
|
175
|
+
* The empty-ID case still routes through BuildCreatedResult, which fails loudly.
|
|
176
|
+
*/
|
|
177
|
+
ExtractIDFromResponse(response, idLocation) {
|
|
178
|
+
if ((!idLocation || idLocation === 'body') && response.Body && typeof response.Body === 'object') {
|
|
179
|
+
const b = response.Body;
|
|
180
|
+
const candidates = ['Id', 'ID', 'PartyId', 'UniformId', 'EventId', 'InvoiceId', 'PaymentId', 'id', 'externalID', 'ExternalID'];
|
|
181
|
+
for (const k of candidates) {
|
|
182
|
+
const v = b[k];
|
|
183
|
+
if (typeof v === 'string' || typeof v === 'number') {
|
|
184
|
+
const s = String(v).trim();
|
|
185
|
+
if (s.length > 0)
|
|
186
|
+
return s;
|
|
187
|
+
}
|
|
100
188
|
}
|
|
101
|
-
return
|
|
189
|
+
return undefined;
|
|
102
190
|
}
|
|
103
|
-
|
|
191
|
+
return super.ExtractIDFromResponse(response, idLocation);
|
|
104
192
|
}
|
|
193
|
+
/** Sends the OAuth2 bearer token on every request. */
|
|
194
|
+
BuildHeaders(auth) {
|
|
195
|
+
const token = auth.Token ?? auth.Token ?? '';
|
|
196
|
+
return {
|
|
197
|
+
'Authorization': `Bearer ${token}`,
|
|
198
|
+
'Accept': 'application/json',
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
GetBaseURL(_companyIntegration, auth) {
|
|
203
|
+
return auth.BaseUrl;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Unwraps the iMIS PagedResult envelope to the Items collection. Handles the envelope,
|
|
207
|
+
* an explicit responseDataKey, a raw array, and a single-object detail response. The FULL
|
|
208
|
+
* source record passes through (no field filtering) so the framework's custom-column capture
|
|
209
|
+
* sees everything the source returned.
|
|
210
|
+
*/
|
|
105
211
|
NormalizeResponse(rawBody, responseDataKey) {
|
|
106
212
|
if (rawBody == null)
|
|
107
213
|
return [];
|
|
108
|
-
// PagedResult envelope → Items
|
|
214
|
+
// PagedResult envelope → Items (ResponseDataKey is typically "Items")
|
|
109
215
|
if (this.IsPagedResult(rawBody)) {
|
|
110
216
|
return rawBody.Items ?? [];
|
|
111
217
|
}
|
|
112
|
-
// Explicit key provided (e.g., "Items")
|
|
113
218
|
if (responseDataKey) {
|
|
114
219
|
const record = rawBody;
|
|
115
220
|
const data = record[responseDataKey];
|
|
@@ -118,19 +223,23 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
|
118
223
|
}
|
|
119
224
|
if (Array.isArray(rawBody))
|
|
120
225
|
return rawBody;
|
|
121
|
-
// Single-object response (
|
|
226
|
+
// Single-object detail response (GET /{Entity}/{id}).
|
|
122
227
|
return [rawBody];
|
|
123
228
|
}
|
|
124
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Derives Offset/Limit pagination state from the iMIS PagedResult envelope: advance Offset by
|
|
231
|
+
* the number of Items returned and continue while HasNext is true (terminate when HasNext is false).
|
|
232
|
+
*/
|
|
233
|
+
ExtractPaginationInfo(rawBody, paginationType, currentPage, currentOffset, _pageSize) {
|
|
125
234
|
if (paginationType === 'None')
|
|
126
235
|
return { HasMore: false };
|
|
127
236
|
const envelope = this.IsPagedResult(rawBody) ? rawBody : null;
|
|
128
237
|
if (!envelope) {
|
|
129
|
-
//
|
|
238
|
+
// No PagedResult envelope → cannot advance → no more pages.
|
|
130
239
|
return { HasMore: false };
|
|
131
240
|
}
|
|
132
|
-
const hasMore = envelope.HasNext ?? false;
|
|
133
241
|
const returnedCount = envelope.Items?.length ?? 0;
|
|
242
|
+
const hasMore = (envelope.HasNext ?? false) && returnedCount > 0;
|
|
134
243
|
const nextOffset = (envelope.Offset ?? currentOffset) + returnedCount;
|
|
135
244
|
return {
|
|
136
245
|
HasMore: hasMore,
|
|
@@ -139,15 +248,12 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
|
139
248
|
TotalRecords: envelope.Count,
|
|
140
249
|
};
|
|
141
250
|
}
|
|
142
|
-
GetBaseURL(_companyIntegration, auth) {
|
|
143
|
-
return auth.BaseUrl;
|
|
144
|
-
}
|
|
145
251
|
/**
|
|
146
|
-
* iMIS pagination uses `Offset`
|
|
147
|
-
*
|
|
252
|
+
* iMIS pagination uses capitalized `Offset` / `Limit` query parameters. Override the base
|
|
253
|
+
* helper to match iMIS's param names.
|
|
148
254
|
*/
|
|
149
255
|
BuildPaginatedURL(basePath, obj, page, offset, cursor, effectivePageSize) {
|
|
150
|
-
const pageSize = effectivePageSize ?? obj.DefaultPageSize ??
|
|
256
|
+
const pageSize = effectivePageSize ?? obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
|
|
151
257
|
const separator = basePath.includes('?') ? '&' : '?';
|
|
152
258
|
switch (obj.PaginationType) {
|
|
153
259
|
case 'PageNumber':
|
|
@@ -163,156 +269,152 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
|
163
269
|
}
|
|
164
270
|
}
|
|
165
271
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
272
|
+
* Injects the incremental watermark filter when a sync is active. The param NAME is fully
|
|
273
|
+
* METADATA-DRIVEN: it comes from the IO's `IncrementalWatermarkField` and is emitted only when
|
|
274
|
+
* SupportsIncrementalSync=true AND a watermark value is in context. iMIS accepts operator-prefixed
|
|
275
|
+
* filter values (`gt:`) on list endpoints.
|
|
168
276
|
*/
|
|
169
277
|
AppendDefaultQueryParams(url, obj) {
|
|
170
278
|
let result = super.AppendDefaultQueryParams(url, obj);
|
|
171
|
-
|
|
279
|
+
const watermarkField = obj.IncrementalWatermarkField;
|
|
280
|
+
if (obj.SupportsIncrementalSync && watermarkField && this.currentWatermark) {
|
|
172
281
|
const separator = result.includes('?') ? '&' : '?';
|
|
173
|
-
const param =
|
|
282
|
+
const param = `${encodeURIComponent(watermarkField)}=${encodeURIComponent('gt:' + this.currentWatermark)}`;
|
|
174
283
|
result = `${result}${separator}${param}`;
|
|
175
284
|
}
|
|
176
285
|
return result;
|
|
177
286
|
}
|
|
178
|
-
|
|
287
|
+
/** Executes an HTTP request with client-side throttling + retry for 401/429/5xx. */
|
|
288
|
+
async MakeHTTPRequest(auth, url, method, headers, body) {
|
|
289
|
+
const imisAuth = auth;
|
|
290
|
+
const maxRetries = imisAuth.Config.MaxRetries ?? DEFAULT_MAX_RETRIES;
|
|
291
|
+
const timeoutMs = imisAuth.Config.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
292
|
+
const minInterval = imisAuth.Config.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
|
|
293
|
+
const effectiveHeaders = { ...headers };
|
|
294
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
295
|
+
await this.ThrottleIfNeeded(minInterval);
|
|
296
|
+
const fetchOptions = {
|
|
297
|
+
method,
|
|
298
|
+
headers: effectiveHeaders,
|
|
299
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
300
|
+
};
|
|
301
|
+
if (body !== undefined && method !== 'GET') {
|
|
302
|
+
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
303
|
+
}
|
|
304
|
+
let response;
|
|
305
|
+
try {
|
|
306
|
+
response = await fetch(url, fetchOptions);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (attempt < maxRetries && this.IsTransientNetworkError(err)) {
|
|
310
|
+
await this.Sleep(this.BackoffDelay(attempt));
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
this.lastRequestTime = Date.now();
|
|
316
|
+
// Token expired mid-run — force a fresh mint and retry once with the new bearer.
|
|
317
|
+
if (response.status === 401 && attempt < maxRetries) {
|
|
318
|
+
this.tokenManager.Reset();
|
|
319
|
+
this.authCache = null;
|
|
320
|
+
const fresh = await this.MintToken(imisAuth.Config, imisAuth.BaseUrl);
|
|
321
|
+
imisAuth.Token = fresh;
|
|
322
|
+
this.authCache = imisAuth;
|
|
323
|
+
effectiveHeaders['Authorization'] = `Bearer ${fresh}`;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if ((response.status === 429 || response.status >= 500) && attempt < maxRetries) {
|
|
327
|
+
console.warn(`[iMIS] HTTP ${response.status} from ${url} — backing off`);
|
|
328
|
+
await this.Sleep(this.RetryAfterMs(response) ?? this.BackoffDelay(attempt));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
return this.BuildRESTResponse(response);
|
|
332
|
+
}
|
|
333
|
+
throw new Error(`iMIS request failed after ${maxRetries + 1} attempts: ${url}`);
|
|
334
|
+
}
|
|
335
|
+
// ─── TestConnection ──────────────────────────────────────────────
|
|
179
336
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
337
|
+
* Tests connectivity by minting a password-grant token and listing one Party record. A 2xx
|
|
338
|
+
* confirms the OAuth2 credentials + base URL are valid against the live API.
|
|
182
339
|
*/
|
|
183
340
|
async TestConnection(companyIntegration, contextUser) {
|
|
184
341
|
try {
|
|
185
|
-
const auth = await this.
|
|
186
|
-
const url = `${auth.BaseUrl}/Party?Limit=1`;
|
|
342
|
+
const auth = (await this.Authenticate(companyIntegration, contextUser));
|
|
187
343
|
const headers = this.BuildHeaders(auth);
|
|
344
|
+
const url = `${auth.BaseUrl}/Party?Limit=1`;
|
|
188
345
|
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
189
|
-
if (response.Status
|
|
190
|
-
return {
|
|
191
|
-
Success: true,
|
|
192
|
-
Message: `Connected to iMIS EMS at ${auth.BaseUrl}`,
|
|
193
|
-
ServerVersion: this.ExtractServerVersion(response.Headers),
|
|
194
|
-
};
|
|
346
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
347
|
+
return { Success: false, Message: `iMIS returned HTTP ${response.Status} from ${url}` };
|
|
195
348
|
}
|
|
196
|
-
return {
|
|
349
|
+
return {
|
|
350
|
+
Success: true,
|
|
351
|
+
Message: `Connected to iMIS EMS at ${auth.BaseUrl}`,
|
|
352
|
+
ServerVersion: this.ExtractServerVersion(response.Headers),
|
|
353
|
+
};
|
|
197
354
|
}
|
|
198
355
|
catch (err) {
|
|
199
356
|
const message = err instanceof Error ? err.message : String(err);
|
|
200
357
|
return { Success: false, Message: `Connection failed: ${message}` };
|
|
201
358
|
}
|
|
202
359
|
}
|
|
360
|
+
// ─── Discovery (metadata-driven baseline + runtime mechanism) ────
|
|
203
361
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
362
|
+
* Discovers objects: the Declared core baseline from the IntegrationEngineBase cache, ADDITIVELY
|
|
363
|
+
* extended at runtime via the auth-gated /EntityDefinition endpoint (the tenant's customer
|
|
364
|
+
* Business-Object catalog). The catalog is NEVER baked into code — DiscoverObjects expresses only
|
|
365
|
+
* the discovery MECHANISM. Failures degrade gracefully to the Declared baseline.
|
|
208
366
|
*/
|
|
209
367
|
async DiscoverObjects(companyIntegration, contextUser) {
|
|
210
|
-
const
|
|
211
|
-
const
|
|
368
|
+
const declared = await super.DiscoverObjects(companyIntegration, contextUser);
|
|
369
|
+
const declaredNames = new Set(declared.map(o => o.Name.toLowerCase()));
|
|
212
370
|
try {
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
return [...
|
|
371
|
+
const discovered = await this.DiscoverEntityDefinitions(companyIntegration, contextUser);
|
|
372
|
+
const additive = discovered.filter(d => !declaredNames.has(d.Name.toLowerCase()));
|
|
373
|
+
return [...declared, ...additive];
|
|
216
374
|
}
|
|
217
375
|
catch (err) {
|
|
218
376
|
const message = err instanceof Error ? err.message : String(err);
|
|
219
|
-
console.warn(`[iMIS]
|
|
220
|
-
return
|
|
377
|
+
console.warn(`[iMIS] EntityDefinition discovery failed, returning declared objects only: ${message}`);
|
|
378
|
+
return declared;
|
|
221
379
|
}
|
|
222
380
|
}
|
|
223
381
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* — any live field not in the static metadata is appended with IsCustom=true
|
|
228
|
-
* (surfaced to callers via the IsForeignKey=false / IsReadOnly mix from the live API).
|
|
382
|
+
* Discovers fields: the Declared baseline from the cache, ADDITIVELY extended at runtime via the
|
|
383
|
+
* auth-gated /{entity}/metadata endpoint (the tenant's PropertyDefinitions). The field set is
|
|
384
|
+
* NEVER baked in code. Failures degrade gracefully to the Declared baseline.
|
|
229
385
|
*/
|
|
230
386
|
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
231
|
-
const
|
|
387
|
+
const declared = await super.DiscoverFields(companyIntegration, objectName, contextUser);
|
|
232
388
|
try {
|
|
233
|
-
const
|
|
234
|
-
return this.MergeFieldSchemas(
|
|
389
|
+
const live = await this.DiscoverFieldsFromLiveAPI(companyIntegration, objectName, contextUser);
|
|
390
|
+
return this.MergeFieldSchemas(declared, live);
|
|
235
391
|
}
|
|
236
392
|
catch (err) {
|
|
237
393
|
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
-
console.warn(`[iMIS] Live field discovery failed for "${objectName}", returning
|
|
239
|
-
return
|
|
394
|
+
console.warn(`[iMIS] Live field discovery failed for "${objectName}", returning declared fields: ${message}`);
|
|
395
|
+
return declared;
|
|
240
396
|
}
|
|
241
397
|
}
|
|
398
|
+
// ─── FetchChanges override (watermark wiring) ────────────────────
|
|
242
399
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
400
|
+
* Sets the watermark context the URL builder needs, delegates the walk to the base (which handles
|
|
401
|
+
* pagination + nested template-var paths), then advances the watermark from the returned records
|
|
402
|
+
* on the FINAL batch only (partial-failure-safe — a mid-stream failure leaves the watermark unchanged).
|
|
245
403
|
*/
|
|
246
404
|
async FetchChanges(ctx) {
|
|
247
|
-
this.currentWatermark = ctx.WatermarkValue;
|
|
405
|
+
this.currentWatermark = ctx.WatermarkValue ?? undefined;
|
|
248
406
|
try {
|
|
249
407
|
const result = await super.FetchChanges(ctx);
|
|
250
|
-
|
|
408
|
+
const isFinal = !result.HasMore;
|
|
409
|
+
result.NewWatermarkValue = isFinal
|
|
410
|
+
? this.ComputeNewWatermark(result.Records, ctx.WatermarkValue)
|
|
411
|
+
: undefined;
|
|
251
412
|
return result;
|
|
252
413
|
}
|
|
253
414
|
finally {
|
|
254
|
-
this.currentWatermark =
|
|
415
|
+
this.currentWatermark = undefined;
|
|
255
416
|
}
|
|
256
417
|
}
|
|
257
|
-
// ── Write operations ────────────────────────────────────────────
|
|
258
|
-
async CreateRecord(ctx) {
|
|
259
|
-
const companyIntegration = ctx.CompanyIntegration;
|
|
260
|
-
const contextUser = ctx.ContextUser;
|
|
261
|
-
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
262
|
-
const url = `${auth.BaseUrl}/${this.NormalizeObjectPath(ctx.ObjectName)}`;
|
|
263
|
-
const headers = this.BuildHeaders(auth);
|
|
264
|
-
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, ctx.Attributes);
|
|
265
|
-
if (response.Status >= 200 && response.Status < 300) {
|
|
266
|
-
const body = response.Body;
|
|
267
|
-
return this.BuildCreatedResult(this.ExtractIdFromRecord(body), response.Status, ctx.ObjectName);
|
|
268
|
-
}
|
|
269
|
-
return this.BuildCRUDError(response, 'CreateRecord', ctx.ObjectName);
|
|
270
|
-
}
|
|
271
|
-
async UpdateRecord(ctx) {
|
|
272
|
-
const companyIntegration = ctx.CompanyIntegration;
|
|
273
|
-
const contextUser = ctx.ContextUser;
|
|
274
|
-
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
275
|
-
const objectPath = this.NormalizeObjectPath(ctx.ObjectName);
|
|
276
|
-
// iMIS PUT is a full replace — do a read-modify-write merge of Attributes
|
|
277
|
-
// into the current record so callers can supply partial updates.
|
|
278
|
-
const current = await this.FetchSingleRecord(auth, objectPath, ctx.ExternalID);
|
|
279
|
-
if (!current) {
|
|
280
|
-
return { Success: false, ErrorMessage: `Record ${ctx.ExternalID} not found`, StatusCode: 404 };
|
|
281
|
-
}
|
|
282
|
-
const merged = { ...current, ...ctx.Attributes };
|
|
283
|
-
const url = `${auth.BaseUrl}/${objectPath}/${encodeURIComponent(ctx.ExternalID)}`;
|
|
284
|
-
const headers = this.BuildHeaders(auth);
|
|
285
|
-
const response = await this.MakeHTTPRequest(auth, url, 'PUT', headers, merged);
|
|
286
|
-
if (response.Status >= 200 && response.Status < 300) {
|
|
287
|
-
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
|
|
288
|
-
}
|
|
289
|
-
return this.BuildCRUDError(response, 'UpdateRecord', ctx.ObjectName);
|
|
290
|
-
}
|
|
291
|
-
async DeleteRecord(ctx) {
|
|
292
|
-
const companyIntegration = ctx.CompanyIntegration;
|
|
293
|
-
const contextUser = ctx.ContextUser;
|
|
294
|
-
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
295
|
-
const url = `${auth.BaseUrl}/${this.NormalizeObjectPath(ctx.ObjectName)}/${encodeURIComponent(ctx.ExternalID)}`;
|
|
296
|
-
const headers = this.BuildHeaders(auth);
|
|
297
|
-
const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
|
|
298
|
-
if (response.Status >= 200 && response.Status < 300) {
|
|
299
|
-
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
|
|
300
|
-
}
|
|
301
|
-
return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
|
|
302
|
-
}
|
|
303
|
-
async GetRecord(ctx) {
|
|
304
|
-
const companyIntegration = ctx.CompanyIntegration;
|
|
305
|
-
const contextUser = ctx.ContextUser;
|
|
306
|
-
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
307
|
-
const record = await this.FetchSingleRecord(auth, this.NormalizeObjectPath(ctx.ObjectName), ctx.ExternalID);
|
|
308
|
-
if (!record)
|
|
309
|
-
return null;
|
|
310
|
-
return {
|
|
311
|
-
ExternalID: ctx.ExternalID,
|
|
312
|
-
ObjectType: ctx.ObjectName,
|
|
313
|
-
Fields: record,
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
418
|
// ── Default configuration / field mappings ──────────────────────
|
|
317
419
|
GetDefaultConfiguration() {
|
|
318
420
|
return {
|
|
@@ -344,152 +446,27 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
|
344
446
|
// ─────────────────────────────────────────────────────────────────
|
|
345
447
|
// Private helpers
|
|
346
448
|
// ─────────────────────────────────────────────────────────────────
|
|
347
|
-
// ──
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
* or a pre-parsed config for silent refresh from MakeHTTPRequest.
|
|
352
|
-
*/
|
|
353
|
-
async GetAuth(source, contextUser, forceRefresh = false) {
|
|
354
|
-
if (!forceRefresh && this.authState && this.IsTokenValid(this.authState)) {
|
|
355
|
-
return this.authState;
|
|
356
|
-
}
|
|
357
|
-
const config = this.IsConfig(source)
|
|
358
|
-
? source
|
|
359
|
-
: await this.ParseConfig(source, contextUser);
|
|
360
|
-
this.authState = await this.AcquireToken(config);
|
|
361
|
-
return this.authState;
|
|
362
|
-
}
|
|
363
|
-
IsConfig(source) {
|
|
364
|
-
return source.BaseUrl !== undefined
|
|
365
|
-
&& source.Username !== undefined;
|
|
366
|
-
}
|
|
367
|
-
IsTokenValid(auth) {
|
|
368
|
-
return auth.ExpiresAt.getTime() > Date.now() + TOKEN_REFRESH_BUFFER_MS;
|
|
369
|
-
}
|
|
370
|
-
async AcquireToken(config) {
|
|
371
|
-
const baseUrl = this.StripTrailingSlash(config.BaseUrl);
|
|
372
|
-
const tokenUrl = this.ResolveTokenEndpoint(baseUrl);
|
|
373
|
-
const body = this.BuildTokenRequestBody(config);
|
|
374
|
-
const response = await fetch(tokenUrl, {
|
|
375
|
-
method: 'POST',
|
|
376
|
-
headers: {
|
|
377
|
-
'Accept': 'application/json',
|
|
378
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
379
|
-
},
|
|
380
|
-
body,
|
|
381
|
-
});
|
|
382
|
-
if (!response.ok) {
|
|
383
|
-
const text = await response.text();
|
|
384
|
-
throw new Error(`iMIS token request failed (${response.status}): ${text.slice(0, 300)}`);
|
|
385
|
-
}
|
|
386
|
-
const payload = await response.json();
|
|
387
|
-
const expiresAt = new Date(Date.now() + (payload.expires_in ?? 3600) * 1000);
|
|
388
|
-
if (payload.refresh_token) {
|
|
389
|
-
config.RefreshToken = payload.refresh_token;
|
|
390
|
-
}
|
|
391
|
-
return {
|
|
392
|
-
Token: payload.access_token,
|
|
393
|
-
ExpiresAt: expiresAt,
|
|
394
|
-
Config: config,
|
|
395
|
-
BaseUrl: baseUrl,
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* iMIS issues tokens at the scheduler path, not under /api/. Strip /api[/]
|
|
400
|
-
* from the BaseUrl when constructing the token endpoint.
|
|
401
|
-
*/
|
|
402
|
-
ResolveTokenEndpoint(baseUrl) {
|
|
403
|
-
const trimmed = baseUrl.replace(/\/api\/?$/i, '');
|
|
404
|
-
// Vendor-canonical path is lowercase '/token/' per developer.imis.com docs.
|
|
405
|
-
// IIS is normally case-insensitive on URL paths so '/Token' usually works,
|
|
406
|
-
// but a few customer reverse-proxies enforce case strictness — match docs.
|
|
407
|
-
return `${trimmed}/token/`;
|
|
408
|
-
}
|
|
409
|
-
BuildTokenRequestBody(config) {
|
|
410
|
-
const params = new URLSearchParams();
|
|
411
|
-
if (config.ClientId && config.ClientSecret && config.RefreshToken) {
|
|
412
|
-
params.set('grant_type', 'refresh_token');
|
|
413
|
-
params.set('refresh_token', config.RefreshToken);
|
|
414
|
-
params.set('client_id', config.ClientId);
|
|
415
|
-
params.set('client_secret', config.ClientSecret);
|
|
416
|
-
return params.toString();
|
|
417
|
-
}
|
|
418
|
-
params.set('grant_type', 'password');
|
|
419
|
-
params.set('username', config.Username);
|
|
420
|
-
params.set('password', config.Password);
|
|
421
|
-
return params.toString();
|
|
422
|
-
}
|
|
423
|
-
// ── Configuration parsing ───────────────────────────────────────
|
|
424
|
-
async ParseConfig(companyIntegration, contextUser) {
|
|
425
|
-
if (companyIntegration.CredentialID) {
|
|
426
|
-
const config = await this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
|
|
427
|
-
if (config)
|
|
428
|
-
return config;
|
|
429
|
-
}
|
|
430
|
-
if (companyIntegration.Configuration) {
|
|
431
|
-
const parsed = JSON.parse(companyIntegration.Configuration);
|
|
432
|
-
return this.ExtractConfig(parsed);
|
|
433
|
-
}
|
|
434
|
-
throw new Error('iMIS connector requires either CredentialID or Configuration JSON');
|
|
435
|
-
}
|
|
436
|
-
async ParseConfigFromCredential(credentialID, contextUser, provider) {
|
|
437
|
-
const md = provider ?? new Metadata();
|
|
438
|
-
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
439
|
-
const loaded = await credential.Load(credentialID);
|
|
440
|
-
if (!loaded || !credential.Values)
|
|
441
|
-
return null;
|
|
442
|
-
const values = JSON.parse(credential.Values);
|
|
443
|
-
return this.ExtractConfig(values);
|
|
444
|
-
}
|
|
445
|
-
ExtractConfig(values) {
|
|
446
|
-
const get = (...keys) => {
|
|
447
|
-
for (const key of keys) {
|
|
448
|
-
const hit = Object.entries(values).find(([k]) => k.toLowerCase() === key.toLowerCase());
|
|
449
|
-
if (hit)
|
|
450
|
-
return String(hit[1] ?? '');
|
|
451
|
-
}
|
|
452
|
-
return undefined;
|
|
453
|
-
};
|
|
454
|
-
const baseUrl = get('BaseUrl', 'baseUrl', 'base_url');
|
|
455
|
-
const username = get('Username', 'username', 'user');
|
|
456
|
-
const password = get('Password', 'password', 'pass');
|
|
457
|
-
if (!baseUrl)
|
|
458
|
-
throw new Error('iMIS configuration missing required field: BaseUrl');
|
|
459
|
-
if (!username)
|
|
460
|
-
throw new Error('iMIS configuration missing required field: Username');
|
|
461
|
-
if (!password)
|
|
462
|
-
throw new Error('iMIS configuration missing required field: Password');
|
|
463
|
-
return {
|
|
464
|
-
BaseUrl: this.StripTrailingSlash(baseUrl),
|
|
465
|
-
Username: username,
|
|
466
|
-
Password: password,
|
|
467
|
-
ClientId: get('ClientId', 'clientId', 'client_id'),
|
|
468
|
-
ClientSecret: get('ClientSecret', 'clientSecret', 'client_secret'),
|
|
469
|
-
RefreshToken: get('RefreshToken', 'refreshToken', 'refresh_token'),
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
// ── Runtime discovery (BOs + field metadata) ────────────────────
|
|
473
|
-
async DiscoverBusinessObjects(companyIntegration, contextUser) {
|
|
474
|
-
const auth = await this.GetAuth(companyIntegration, contextUser);
|
|
475
|
-
const url = `${auth.BaseUrl}/BOEntityDefinition?Limit=500`;
|
|
449
|
+
// ── Runtime discovery (Business Objects + field metadata) ───────
|
|
450
|
+
async DiscoverEntityDefinitions(companyIntegration, contextUser) {
|
|
451
|
+
const auth = (await this.Authenticate(companyIntegration, contextUser));
|
|
452
|
+
const url = `${auth.BaseUrl}/EntityDefinition?Limit=500`;
|
|
476
453
|
const headers = this.BuildHeaders(auth);
|
|
477
454
|
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
478
455
|
if (response.Status < 200 || response.Status >= 300)
|
|
479
456
|
return [];
|
|
480
457
|
const records = this.NormalizeResponse(response.Body, 'Items');
|
|
481
458
|
return records
|
|
482
|
-
.filter(
|
|
483
|
-
.map(
|
|
484
|
-
Name: String(
|
|
485
|
-
Label:
|
|
486
|
-
Description:
|
|
459
|
+
.filter(d => !!d.Name)
|
|
460
|
+
.map(d => ({
|
|
461
|
+
Name: String(d.Name),
|
|
462
|
+
Label: d.DisplayName ?? String(d.Name),
|
|
463
|
+
Description: d.Description ?? 'Tenant-defined Business Object (runtime-discovered)',
|
|
487
464
|
SupportsIncrementalSync: true,
|
|
488
|
-
SupportsWrite:
|
|
465
|
+
SupportsWrite: false,
|
|
489
466
|
}));
|
|
490
467
|
}
|
|
491
468
|
async DiscoverFieldsFromLiveAPI(companyIntegration, objectName, contextUser) {
|
|
492
|
-
const auth = await this.
|
|
469
|
+
const auth = (await this.Authenticate(companyIntegration, contextUser));
|
|
493
470
|
const path = this.ResolveObjectPath(companyIntegration.IntegrationID, objectName);
|
|
494
471
|
const url = `${auth.BaseUrl}${path}/metadata`;
|
|
495
472
|
const headers = this.BuildHeaders(auth);
|
|
@@ -511,25 +488,25 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
|
511
488
|
Description: prop.Description,
|
|
512
489
|
DataType: genericType,
|
|
513
490
|
IsRequired: !!prop.IsRequired,
|
|
491
|
+
// Provable-only: the source explicitly distinguishes PK; only set PK when stated.
|
|
492
|
+
IsPrimaryKey: prop.IsPrimaryKey === true ? true : undefined,
|
|
514
493
|
IsUniqueKey: !!prop.IsPrimaryKey,
|
|
515
494
|
IsReadOnly: !!prop.IsReadOnly,
|
|
495
|
+
MaxLength: prop.MaxLength ?? undefined,
|
|
516
496
|
};
|
|
517
497
|
}
|
|
518
498
|
/**
|
|
519
|
-
* Merge live-discovered fields with
|
|
520
|
-
*
|
|
521
|
-
* Static fields override live fields on matching names (static metadata
|
|
522
|
-
* reflects the curated MJ schema).
|
|
499
|
+
* Merge live-discovered fields with the Declared baseline. Live-only fields are appended;
|
|
500
|
+
* Declared fields win on matching names (the curated MJ schema is authoritative for the baseline).
|
|
523
501
|
*/
|
|
524
|
-
MergeFieldSchemas(
|
|
525
|
-
const
|
|
526
|
-
const liveOnly =
|
|
527
|
-
return [...
|
|
502
|
+
MergeFieldSchemas(declared, live) {
|
|
503
|
+
const declaredNames = new Set(declared.map(f => f.Name.toLowerCase()));
|
|
504
|
+
const liveOnly = live.filter(f => !declaredNames.has(f.Name.toLowerCase()));
|
|
505
|
+
return [...declared, ...liveOnly];
|
|
528
506
|
}
|
|
529
507
|
/**
|
|
530
|
-
* Resolve an object name to its API path. Prefers the configured APIPath
|
|
531
|
-
*
|
|
532
|
-
* runtime-discovered Business Objects.
|
|
508
|
+
* Resolve an object name to its API path. Prefers the configured APIPath from IntegrationObject
|
|
509
|
+
* metadata; falls back to `/{ObjectName}` for runtime-discovered Business Objects.
|
|
533
510
|
*/
|
|
534
511
|
ResolveObjectPath(integrationID, objectName) {
|
|
535
512
|
try {
|
|
@@ -539,34 +516,96 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
|
539
516
|
}
|
|
540
517
|
}
|
|
541
518
|
catch {
|
|
542
|
-
// Not in
|
|
519
|
+
// Not in Declared metadata — a runtime-discovered Business Object.
|
|
543
520
|
}
|
|
544
521
|
return `/${objectName}`;
|
|
545
522
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
523
|
+
// ── Config parsing ──────────────────────────────────────────────
|
|
524
|
+
/**
|
|
525
|
+
* Parses the connection config, preferring the attached MJ Credential over the raw Configuration
|
|
526
|
+
* JSON. Credential bytes are resolved at runtime — never at build.
|
|
527
|
+
*/
|
|
528
|
+
async ParseConfig(companyIntegration, contextUser) {
|
|
529
|
+
if (companyIntegration.CredentialID) {
|
|
530
|
+
return this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser);
|
|
531
|
+
}
|
|
532
|
+
if (companyIntegration.Configuration) {
|
|
533
|
+
return this.ValidateConfig(JSON.parse(companyIntegration.Configuration));
|
|
534
|
+
}
|
|
535
|
+
throw new Error('iMIS connector requires either CredentialID or Configuration JSON');
|
|
549
536
|
}
|
|
550
|
-
|
|
551
|
-
async
|
|
552
|
-
const
|
|
553
|
-
const
|
|
554
|
-
const
|
|
555
|
-
if (
|
|
556
|
-
|
|
557
|
-
if (response.Body == null || typeof response.Body !== 'object')
|
|
558
|
-
return null;
|
|
559
|
-
return response.Body;
|
|
560
|
-
}
|
|
561
|
-
ExtractIdFromRecord(body) {
|
|
562
|
-
const candidates = ['Id', 'ID', 'PartyId', 'UniformId', 'EventId', 'InvoiceId', 'PaymentId'];
|
|
563
|
-
for (const key of candidates) {
|
|
564
|
-
const value = body[key];
|
|
565
|
-
if (value !== undefined && value !== null)
|
|
566
|
-
return String(value);
|
|
537
|
+
/** Loads the config from the MJ: Credentials entity Values JSON. */
|
|
538
|
+
async ParseConfigFromCredential(credentialID, contextUser, provider) {
|
|
539
|
+
const md = provider ?? new Metadata();
|
|
540
|
+
const cred = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
541
|
+
const loaded = await cred.Load(credentialID);
|
|
542
|
+
if (!loaded || !cred.Values) {
|
|
543
|
+
throw new Error('iMIS credential could not be loaded or has no Values JSON');
|
|
567
544
|
}
|
|
568
|
-
return
|
|
545
|
+
return this.ValidateConfig(JSON.parse(cred.Values));
|
|
546
|
+
}
|
|
547
|
+
/** Validates the parsed config + applies defaults. Field names are case-insensitive. */
|
|
548
|
+
ValidateConfig(raw) {
|
|
549
|
+
if (!raw || typeof raw !== 'object') {
|
|
550
|
+
throw new Error('iMIS configuration is not a valid object');
|
|
551
|
+
}
|
|
552
|
+
const obj = raw;
|
|
553
|
+
const getStr = (...keys) => {
|
|
554
|
+
for (const key of keys) {
|
|
555
|
+
const lower = key.toLowerCase();
|
|
556
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
557
|
+
if (k.toLowerCase() === lower && typeof v === 'string' && v.length > 0)
|
|
558
|
+
return v;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return undefined;
|
|
562
|
+
};
|
|
563
|
+
const getNum = (...keys) => {
|
|
564
|
+
for (const key of keys) {
|
|
565
|
+
const lower = key.toLowerCase();
|
|
566
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
567
|
+
if (k.toLowerCase() === lower && typeof v === 'number')
|
|
568
|
+
return v;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return undefined;
|
|
572
|
+
};
|
|
573
|
+
const baseURL = getStr('baseurl', 'base_url');
|
|
574
|
+
if (!baseURL)
|
|
575
|
+
throw new Error('iMIS configuration missing required field: BaseURL');
|
|
576
|
+
const username = getStr('username', 'user');
|
|
577
|
+
const password = getStr('password', 'pass');
|
|
578
|
+
if (!username)
|
|
579
|
+
throw new Error('iMIS configuration missing required field: Username');
|
|
580
|
+
if (!password)
|
|
581
|
+
throw new Error('iMIS configuration missing required field: Password');
|
|
582
|
+
return {
|
|
583
|
+
BaseURL: baseURL,
|
|
584
|
+
Username: username,
|
|
585
|
+
Password: password,
|
|
586
|
+
TokenURL: getStr('tokenurl', 'token_url'),
|
|
587
|
+
ClientId: getStr('clientid', 'client_id'),
|
|
588
|
+
ClientSecret: getStr('clientsecret', 'client_secret'),
|
|
589
|
+
MaxRetries: getNum('maxretries') ?? DEFAULT_MAX_RETRIES,
|
|
590
|
+
RequestTimeoutMs: getNum('requesttimeoutms') ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
591
|
+
MinRequestIntervalMs: getNum('minrequestintervalms') ?? DEFAULT_MIN_REQUEST_INTERVAL_MS,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
/** Resolves the API base URL from the credential's BaseURL (never a hardcoded host). */
|
|
595
|
+
ResolveBaseUrl(config) {
|
|
596
|
+
return config.BaseURL.replace(/\/+$/, '');
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Resolves the token endpoint: credential TokenURL override, else `{base}/token`. iMIS issues
|
|
600
|
+
* tokens at the host root (not under /api/), so a trailing /api segment is stripped.
|
|
601
|
+
*/
|
|
602
|
+
ResolveTokenURL(config, baseUrl) {
|
|
603
|
+
if (config.TokenURL && config.TokenURL.length > 0)
|
|
604
|
+
return config.TokenURL;
|
|
605
|
+
const root = baseUrl.replace(/\/api$/i, '');
|
|
606
|
+
return `${root}${DEFAULT_TOKEN_PATH}`;
|
|
569
607
|
}
|
|
608
|
+
// ── Watermark helper ────────────────────────────────────────────
|
|
570
609
|
ComputeNewWatermark(records, current) {
|
|
571
610
|
const candidateKeys = ['UpdatedOn', 'UpdateDate', 'ModifiedOn', 'LastUpdatedAt'];
|
|
572
611
|
let latest;
|
|
@@ -584,99 +623,73 @@ let IMISConnector = class IMISConnector extends BaseRESTIntegrationConnector {
|
|
|
584
623
|
return latest;
|
|
585
624
|
}
|
|
586
625
|
// ── HTTP helpers ────────────────────────────────────────────────
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
626
|
+
/** Throttle to respect the configured minimum request interval. */
|
|
627
|
+
async ThrottleIfNeeded(minIntervalMs) {
|
|
628
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
629
|
+
if (elapsed < minIntervalMs)
|
|
630
|
+
await this.Sleep(minIntervalMs - elapsed);
|
|
631
|
+
}
|
|
632
|
+
/** Parses a Retry-After header (seconds or http-date) into ms, if present. */
|
|
633
|
+
RetryAfterMs(response) {
|
|
634
|
+
const header = response.headers.get('retry-after');
|
|
635
|
+
if (!header)
|
|
636
|
+
return undefined;
|
|
637
|
+
const asSeconds = Number(header);
|
|
638
|
+
if (!isNaN(asSeconds))
|
|
639
|
+
return Math.max(0, asSeconds * 1_000);
|
|
640
|
+
const asDate = new Date(header).getTime();
|
|
641
|
+
if (!isNaN(asDate))
|
|
642
|
+
return Math.max(0, asDate - Date.now());
|
|
643
|
+
return undefined;
|
|
644
|
+
}
|
|
645
|
+
/** Exponential backoff delay for retry attempts (capped at 15s). */
|
|
646
|
+
BackoffDelay(attempt) {
|
|
647
|
+
return Math.min(1_000 * Math.pow(2, attempt), 15_000);
|
|
648
|
+
}
|
|
649
|
+
/** Checks whether an error is transient (network/timeout). */
|
|
650
|
+
IsTransientNetworkError(err) {
|
|
651
|
+
if (!(err instanceof Error))
|
|
652
|
+
return false;
|
|
653
|
+
const msg = err.message.toLowerCase();
|
|
654
|
+
return msg.includes('timeout') || msg.includes('abort') ||
|
|
655
|
+
msg.includes('econnreset') || msg.includes('econnrefused') ||
|
|
656
|
+
msg.includes('fetch failed');
|
|
601
657
|
}
|
|
658
|
+
/** Builds the normalized RESTResponse from a fetch Response. */
|
|
602
659
|
async BuildRESTResponse(response) {
|
|
603
660
|
const headers = {};
|
|
604
661
|
response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
|
|
605
|
-
const
|
|
606
|
-
let
|
|
607
|
-
if (
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
615
|
-
async ParseJsonSafely(response) {
|
|
616
|
-
try {
|
|
617
|
-
return await response.json();
|
|
618
|
-
}
|
|
619
|
-
catch {
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
ShouldRetry(status) {
|
|
624
|
-
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
625
|
-
}
|
|
626
|
-
ComputeRetryDelay(response, attempt) {
|
|
627
|
-
const retryAfter = response.headers.get('retry-after');
|
|
628
|
-
if (retryAfter) {
|
|
629
|
-
const seconds = Number.parseInt(retryAfter, 10);
|
|
630
|
-
if (!Number.isNaN(seconds) && seconds > 0)
|
|
631
|
-
return seconds * 1000;
|
|
662
|
+
const text = await response.text();
|
|
663
|
+
let body = null;
|
|
664
|
+
if (text.length > 0) {
|
|
665
|
+
try {
|
|
666
|
+
body = JSON.parse(text);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
body = text;
|
|
670
|
+
}
|
|
632
671
|
}
|
|
633
|
-
return
|
|
634
|
-
}
|
|
635
|
-
async Throttle(minIntervalMs) {
|
|
636
|
-
const elapsed = Date.now() - this.lastRequestTime;
|
|
637
|
-
if (elapsed < minIntervalMs)
|
|
638
|
-
await this.Sleep(minIntervalMs - elapsed);
|
|
639
|
-
}
|
|
640
|
-
Sleep(ms) {
|
|
641
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
672
|
+
return { Status: response.status, Body: body, Headers: headers };
|
|
642
673
|
}
|
|
643
674
|
ExtractServerVersion(headers) {
|
|
644
675
|
return headers['x-imis-version'] ?? headers['server'] ?? 'iMIS REST API';
|
|
645
676
|
}
|
|
677
|
+
/** True when a body is the iMIS PagedResult envelope (Items[] + Offset/Limit/HasNext). */
|
|
646
678
|
IsPagedResult(body) {
|
|
647
679
|
if (body == null || typeof body !== 'object' || Array.isArray(body))
|
|
648
680
|
return false;
|
|
649
681
|
const record = body;
|
|
650
682
|
return Array.isArray(record['Items']) && ('Offset' in record || 'Limit' in record || 'HasNext' in record);
|
|
651
683
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
BuildCRUDError(response, operation, objectName) {
|
|
656
|
-
const body = response.Body;
|
|
657
|
-
let detail;
|
|
658
|
-
if (typeof body === 'string') {
|
|
659
|
-
detail = body.slice(0, 500);
|
|
660
|
-
}
|
|
661
|
-
else if (body && typeof body === 'object') {
|
|
662
|
-
const asRecord = body;
|
|
663
|
-
const msg = asRecord['Message'] ?? asRecord['error_description'] ?? asRecord['error'];
|
|
664
|
-
detail = msg !== undefined ? String(msg) : JSON.stringify(body).slice(0, 500);
|
|
665
|
-
}
|
|
666
|
-
else {
|
|
667
|
-
detail = `HTTP ${response.Status}`;
|
|
668
|
-
}
|
|
669
|
-
return {
|
|
670
|
-
Success: false,
|
|
671
|
-
ErrorMessage: `iMIS ${operation} failed on ${objectName}: ${detail}`,
|
|
672
|
-
StatusCode: response.Status,
|
|
673
|
-
};
|
|
684
|
+
/** Promise-wrapped setTimeout. */
|
|
685
|
+
Sleep(ms) {
|
|
686
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
674
687
|
}
|
|
675
688
|
};
|
|
676
689
|
IMISConnector = __decorate([
|
|
677
690
|
RegisterClass(BaseIntegrationConnector, 'IMISConnector')
|
|
678
691
|
], IMISConnector);
|
|
679
692
|
export { IMISConnector };
|
|
680
|
-
/** Tree-shaking prevention — import and call from
|
|
693
|
+
/** Tree-shaking prevention — import and call from the package entry point. */
|
|
681
694
|
export function LoadIMISConnector() { }
|
|
682
695
|
//# sourceMappingURL=IMISConnector.js.map
|