tm1npm 1.6.0 → 2.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/CHANGELOG.md +89 -0
- package/lib/services/ApplicationService.d.ts.map +1 -1
- package/lib/services/AsyncOperationService.d.ts +8 -1
- package/lib/services/AsyncOperationService.d.ts.map +1 -1
- package/lib/services/AsyncOperationService.js +69 -26
- package/lib/services/FileService.d.ts.map +1 -1
- package/lib/services/ProcessService.d.ts +18 -13
- package/lib/services/ProcessService.d.ts.map +1 -1
- package/lib/services/ProcessService.js +28 -17
- package/lib/services/RestService.d.ts +213 -25
- package/lib/services/RestService.d.ts.map +1 -1
- package/lib/services/RestService.js +840 -271
- package/lib/services/TM1Service.d.ts +42 -1
- package/lib/services/TM1Service.d.ts.map +1 -1
- package/lib/services/TM1Service.js +94 -4
- package/lib/tests/asyncOperationService.test.js +51 -45
- package/lib/tests/processService.comprehensive.test.js +2 -2
- package/lib/tests/processService.test.js +20 -6
- package/lib/tests/restService.test.d.ts +0 -4
- package/lib/tests/restService.test.d.ts.map +1 -1
- package/lib/tests/restService.test.js +1558 -143
- package/lib/tests/tm1Service.test.js +80 -8
- package/package.json +1 -1
- package/src/services/ApplicationService.ts +4 -4
- package/src/services/AsyncOperationService.ts +76 -29
- package/src/services/FileService.ts +3 -3
- package/src/services/ProcessService.ts +67 -37
- package/src/services/RestService.ts +1020 -278
- package/src/services/TM1Service.ts +124 -6
- package/src/tests/asyncOperationService.test.ts +52 -48
- package/src/tests/processService.comprehensive.test.ts +3 -3
- package/src/tests/processService.test.ts +21 -9
- package/src/tests/restService.test.ts +1844 -139
- package/src/tests/tm1Service.test.ts +95 -11
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import * as fs from 'fs';
|
|
2
4
|
import { TM1RestException, TM1TimeoutException } from '../exceptions/TM1Exception';
|
|
5
|
+
import { formatUrl, CaseAndSpaceInsensitiveSet, caseAndSpaceInsensitiveEquals } from '../utils/Utils';
|
|
6
|
+
|
|
7
|
+
type UrlTopology = 'base_url' | 'v11' | 'ibm_cloud' | 'pa_proxy' | 's2s';
|
|
8
|
+
|
|
9
|
+
const PRODUCT_VERSION_AUTH_SUFFIX = '/Configuration/ProductVersion/$value';
|
|
3
10
|
|
|
4
11
|
export enum AuthenticationMode {
|
|
5
12
|
BASIC = 1,
|
|
@@ -28,6 +35,9 @@ export interface RestServiceConfig {
|
|
|
28
35
|
timeout?: number;
|
|
29
36
|
cancelAtTimeout?: boolean;
|
|
30
37
|
asyncRequestsMode?: boolean;
|
|
38
|
+
asyncPollingInitialDelay?: number;
|
|
39
|
+
asyncPollingMaxDelay?: number;
|
|
40
|
+
asyncPollingBackoffFactor?: number;
|
|
31
41
|
connectionPoolSize?: number;
|
|
32
42
|
poolConnections?: number;
|
|
33
43
|
instance?: string;
|
|
@@ -39,6 +49,42 @@ export interface RestServiceConfig {
|
|
|
39
49
|
apiKey?: string;
|
|
40
50
|
accessToken?: string;
|
|
41
51
|
tenant?: string;
|
|
52
|
+
|
|
53
|
+
// v12 / Cloud URL components
|
|
54
|
+
iamUrl?: string;
|
|
55
|
+
paUrl?: string;
|
|
56
|
+
cpdUrl?: string;
|
|
57
|
+
|
|
58
|
+
// SSO / CAM
|
|
59
|
+
gateway?: string;
|
|
60
|
+
|
|
61
|
+
// Integrated Windows Auth / Kerberos (config surface; auth flow unchanged)
|
|
62
|
+
integratedLogin?: boolean;
|
|
63
|
+
integratedLoginDomain?: string;
|
|
64
|
+
integratedLoginService?: string;
|
|
65
|
+
integratedLoginHost?: string;
|
|
66
|
+
integratedLoginDelegate?: boolean;
|
|
67
|
+
|
|
68
|
+
// Network / TLS
|
|
69
|
+
proxies?: { http?: string; https?: string };
|
|
70
|
+
sslContext?: https.Agent;
|
|
71
|
+
cert?: string | [string, string];
|
|
72
|
+
|
|
73
|
+
// Reconnect behavior (mirrors tm1py kwargs)
|
|
74
|
+
reConnectOnSessionTimeout?: boolean;
|
|
75
|
+
reConnectOnRemoteDisconnect?: boolean;
|
|
76
|
+
remoteDisconnectMaxRetries?: number;
|
|
77
|
+
remoteDisconnectRetryDelay?: number;
|
|
78
|
+
remoteDisconnectMaxDelay?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RequestOptions extends Omit<AxiosRequestConfig, 'timeout'> {
|
|
82
|
+
asyncRequestsMode?: boolean;
|
|
83
|
+
returnAsyncId?: boolean;
|
|
84
|
+
timeout?: number;
|
|
85
|
+
cancelAtTimeout?: boolean;
|
|
86
|
+
idempotent?: boolean;
|
|
87
|
+
verifyResponse?: boolean;
|
|
42
88
|
}
|
|
43
89
|
|
|
44
90
|
export class RestService {
|
|
@@ -53,55 +99,300 @@ export class RestService {
|
|
|
53
99
|
private static readonly DEFAULT_CONNECTION_POOL_SIZE = 10;
|
|
54
100
|
private static readonly DEFAULT_POOL_CONNECTIONS = 1;
|
|
55
101
|
|
|
102
|
+
private static readonly SESSION_COOKIE_NAMES = ['TM1SessionId', 'paSession'] as const;
|
|
103
|
+
|
|
56
104
|
private axiosInstance!: AxiosInstance;
|
|
57
105
|
private config: RestServiceConfig;
|
|
58
|
-
private
|
|
106
|
+
private sessionCookies: Map<string, string> = new Map();
|
|
59
107
|
private sandboxName?: string;
|
|
60
108
|
private isConnected: boolean = false;
|
|
61
109
|
private _serverVersion?: string;
|
|
110
|
+
private _asyncRequestsMode: boolean;
|
|
111
|
+
private _cancelAtTimeout: boolean;
|
|
112
|
+
private _timeout: number;
|
|
113
|
+
private _asyncPollingInitialDelay: number;
|
|
114
|
+
private _asyncPollingMaxDelay: number;
|
|
115
|
+
private _asyncPollingBackoffFactor: number;
|
|
116
|
+
private _reConnectOnSessionTimeout: boolean;
|
|
117
|
+
private _reConnectOnRemoteDisconnect: boolean;
|
|
118
|
+
private _remoteDisconnectMaxRetries: number;
|
|
119
|
+
private _remoteDisconnectRetryDelay: number;
|
|
120
|
+
private _remoteDisconnectMaxDelay: number;
|
|
121
|
+
private _isAdmin?: boolean;
|
|
122
|
+
private _isDataAdmin?: boolean;
|
|
123
|
+
private _isSecurityAdmin?: boolean;
|
|
124
|
+
private _isOpsAdmin?: boolean;
|
|
125
|
+
private _activeUserGroupsPromise?: Promise<CaseAndSpaceInsensitiveSet>;
|
|
62
126
|
|
|
63
127
|
public get version(): string | undefined {
|
|
64
128
|
return this._serverVersion;
|
|
65
129
|
}
|
|
66
130
|
|
|
131
|
+
// Sync accessors for cached role flags. Return false before the first
|
|
132
|
+
// is_*_admin() call resolves — callers that need the real value must
|
|
133
|
+
// await is_admin() / is_data_admin() / etc. first.
|
|
134
|
+
public get isAdmin(): boolean { return this._isAdmin ?? false; }
|
|
135
|
+
public get isDataAdmin(): boolean { return this._isDataAdmin ?? false; }
|
|
136
|
+
public get isSecurityAdmin(): boolean { return this._isSecurityAdmin ?? false; }
|
|
137
|
+
public get isOpsAdmin(): boolean { return this._isOpsAdmin ?? false; }
|
|
138
|
+
|
|
67
139
|
constructor(config: RestServiceConfig) {
|
|
68
140
|
this.config = { ...config };
|
|
141
|
+
this._asyncRequestsMode = config.asyncRequestsMode ?? false;
|
|
142
|
+
this._cancelAtTimeout = config.cancelAtTimeout ?? false;
|
|
143
|
+
this._timeout = config.timeout ?? 60;
|
|
144
|
+
this._asyncPollingInitialDelay = config.asyncPollingInitialDelay ?? 0.1;
|
|
145
|
+
this._asyncPollingMaxDelay = config.asyncPollingMaxDelay ?? 1.0;
|
|
146
|
+
this._asyncPollingBackoffFactor = config.asyncPollingBackoffFactor ?? 2.0;
|
|
147
|
+
this._reConnectOnSessionTimeout = config.reConnectOnSessionTimeout ?? true;
|
|
148
|
+
this._reConnectOnRemoteDisconnect = config.reConnectOnRemoteDisconnect ?? true;
|
|
149
|
+
this._remoteDisconnectMaxRetries = config.remoteDisconnectMaxRetries ?? 5;
|
|
150
|
+
this._remoteDisconnectRetryDelay = config.remoteDisconnectRetryDelay ?? 1;
|
|
151
|
+
this._remoteDisconnectMaxDelay = config.remoteDisconnectMaxDelay ?? 30;
|
|
152
|
+
|
|
153
|
+
// Pre-populate admin flags for the built-in ADMIN user (mirrors tm1py)
|
|
154
|
+
if (config.user && caseAndSpaceInsensitiveEquals(config.user, 'ADMIN')) {
|
|
155
|
+
this._isAdmin = true;
|
|
156
|
+
this._isDataAdmin = true;
|
|
157
|
+
this._isSecurityAdmin = true;
|
|
158
|
+
this._isOpsAdmin = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
69
161
|
this.setupAxiosInstance();
|
|
162
|
+
if (this.config.sessionId) {
|
|
163
|
+
// Mirror tm1py's _set_session_id_cookie: v12 topologies use paSession,
|
|
164
|
+
// v11 and baseUrl overrides use TM1SessionId.
|
|
165
|
+
const topo = this.determineTopology();
|
|
166
|
+
const cookieName = (topo === 'ibm_cloud' || topo === 'pa_proxy' || topo === 's2s')
|
|
167
|
+
? 'paSession'
|
|
168
|
+
: 'TM1SessionId';
|
|
169
|
+
this.sessionCookies.set(cookieName, this.config.sessionId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getSessionCookieValue(): string | undefined {
|
|
174
|
+
for (const name of RestService.SESSION_COOKIE_NAMES) {
|
|
175
|
+
const value = this.sessionCookies.get(name);
|
|
176
|
+
if (value) return value;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private buildCookieHeader(): string | undefined {
|
|
182
|
+
if (this.sessionCookies.size === 0) return undefined;
|
|
183
|
+
const parts: string[] = [];
|
|
184
|
+
for (const [name, value] of this.sessionCookies) {
|
|
185
|
+
parts.push(`${name}=${value}`);
|
|
186
|
+
}
|
|
187
|
+
return parts.join('; ');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private parseSetCookieHeaders(setCookie: string[] | string | undefined): void {
|
|
191
|
+
if (!setCookie) return;
|
|
192
|
+
const list = RestService.normaliseSetCookie(setCookie);
|
|
193
|
+
for (const raw of list) {
|
|
194
|
+
const firstSegment = raw.split(';')[0];
|
|
195
|
+
const eqIdx = firstSegment.indexOf('=');
|
|
196
|
+
if (eqIdx <= 0) continue;
|
|
197
|
+
// Strip CR/LF/NUL defensively to block header-injection via compromised response
|
|
198
|
+
const sanitize = (s: string) => s.replace(/[\r\n\0]/g, '').trim();
|
|
199
|
+
const name = sanitize(firstSegment.slice(0, eqIdx));
|
|
200
|
+
const value = sanitize(firstSegment.slice(eqIdx + 1));
|
|
201
|
+
if (!(RestService.SESSION_COOKIE_NAMES as readonly string[]).includes(name)) continue;
|
|
202
|
+
if (value === '') {
|
|
203
|
+
this.sessionCookies.delete(name);
|
|
204
|
+
} else {
|
|
205
|
+
this.sessionCookies.set(name, value);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private removeAuthorizationHeader(): void {
|
|
211
|
+
delete this.axiosInstance.defaults.headers.common['Authorization'];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private deleteHeaderCaseInsensitive(headers: Record<string, unknown> | undefined, name: string): void {
|
|
215
|
+
if (!headers) return;
|
|
216
|
+
// axios 1.x may supply an AxiosHeaders instance with case-insensitive lookup; plain objects
|
|
217
|
+
// (common in retry paths and test mocks) are case-sensitive and require explicit iteration
|
|
218
|
+
const target = name.toLowerCase();
|
|
219
|
+
for (const key of Object.keys(headers)) {
|
|
220
|
+
if (key.toLowerCase() === target) {
|
|
221
|
+
delete (headers as Record<string, unknown>)[key];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
70
224
|
}
|
|
71
225
|
|
|
72
226
|
private setupAxiosInstance(): void {
|
|
73
227
|
const baseURL = this.buildBaseUrl();
|
|
74
|
-
|
|
75
|
-
|
|
228
|
+
|
|
229
|
+
const axiosConfig: AxiosRequestConfig = {
|
|
76
230
|
baseURL,
|
|
77
|
-
timeout:
|
|
231
|
+
timeout: this._timeout * 1000,
|
|
78
232
|
headers: {
|
|
79
233
|
...RestService.HEADERS,
|
|
80
234
|
...(this.config.sessionContext && { 'TM1-SessionContext': this.config.sessionContext })
|
|
81
235
|
}
|
|
82
|
-
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
if (this.config.proxies) {
|
|
239
|
+
const proxyUrl = this.config.proxies.https || this.config.proxies.http;
|
|
240
|
+
if (proxyUrl) {
|
|
241
|
+
const parsed = new URL(proxyUrl);
|
|
242
|
+
axiosConfig.proxy = {
|
|
243
|
+
host: parsed.hostname,
|
|
244
|
+
port: parsed.port
|
|
245
|
+
? parseInt(parsed.port, 10)
|
|
246
|
+
: (parsed.protocol === 'https:' ? 443 : 80),
|
|
247
|
+
protocol: parsed.protocol.replace(':', ''),
|
|
248
|
+
...(parsed.username && {
|
|
249
|
+
auth: {
|
|
250
|
+
username: decodeURIComponent(parsed.username),
|
|
251
|
+
password: decodeURIComponent(parsed.password)
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (this.config.sslContext) {
|
|
259
|
+
axiosConfig.httpsAgent = this.config.sslContext;
|
|
260
|
+
} else if (this.config.cert) {
|
|
261
|
+
const [certPath, keyPath] = Array.isArray(this.config.cert)
|
|
262
|
+
? this.config.cert
|
|
263
|
+
: [this.config.cert, undefined];
|
|
264
|
+
axiosConfig.httpsAgent = new https.Agent({
|
|
265
|
+
cert: fs.readFileSync(certPath),
|
|
266
|
+
key: keyPath ? fs.readFileSync(keyPath) : undefined
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.axiosInstance = axios.create(axiosConfig);
|
|
83
271
|
|
|
84
272
|
this.setupInterceptors();
|
|
85
273
|
}
|
|
86
274
|
|
|
87
275
|
private buildBaseUrl(): string {
|
|
88
|
-
|
|
89
|
-
|
|
276
|
+
return this.resolveRoots().serviceRoot;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Pick the deployment topology based on the provided config, mirroring
|
|
281
|
+
* tm1py's _determine_auth_mode + _construct_service_and_auth_root dispatch.
|
|
282
|
+
*
|
|
283
|
+
* Note: authUrl is intentionally excluded from the v12 signal set because
|
|
284
|
+
* tm1npm historically uses authUrl for CAM SSO (unlike tm1py, where auth_url
|
|
285
|
+
* is a v12-only field). apiKey is also excluded to avoid collision with the
|
|
286
|
+
* existing BASIC_API_KEY auth flow.
|
|
287
|
+
*/
|
|
288
|
+
private determineTopology(): UrlTopology {
|
|
289
|
+
const c = this.config;
|
|
290
|
+
const hasV12Signal = !!(c.instance || c.database || c.iamUrl || c.paUrl || c.tenant);
|
|
291
|
+
// tm1py's _construct_service_and_auth_root routes v12 modes (IBM Cloud / PA
|
|
292
|
+
// Proxy / S2S) through their dedicated constructors even if base_url is
|
|
293
|
+
// supplied. Only non-v12 configs fall through to the base_url override.
|
|
294
|
+
if (!hasV12Signal) return c.baseUrl ? 'base_url' : 'v11';
|
|
295
|
+
if (c.iamUrl) return 'ibm_cloud';
|
|
296
|
+
if (c.address && c.user && !c.instance) return 'pa_proxy';
|
|
297
|
+
return 's2s';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve the TM1 service root and auth root URLs for the configured topology.
|
|
302
|
+
* Mirrors tm1py's _construct_service_and_auth_root return tuple.
|
|
303
|
+
*/
|
|
304
|
+
private resolveRoots(): { serviceRoot: string; authRoot: string } {
|
|
305
|
+
switch (this.determineTopology()) {
|
|
306
|
+
case 'base_url': return this.rootsFromBaseUrl();
|
|
307
|
+
case 'ibm_cloud': return this.rootsIbmCloud();
|
|
308
|
+
case 'pa_proxy': return this.rootsPaProxy();
|
|
309
|
+
case 's2s': return this.rootsS2s();
|
|
310
|
+
case 'v11':
|
|
311
|
+
default: return this.rootsV11();
|
|
90
312
|
}
|
|
313
|
+
}
|
|
91
314
|
|
|
315
|
+
private rootsV11(): { serviceRoot: string; authRoot: string } {
|
|
92
316
|
const protocol = this.config.ssl ? 'https' : 'http';
|
|
93
317
|
const address = this.config.address || 'localhost';
|
|
94
|
-
const port = this.config.port
|
|
95
|
-
|
|
96
|
-
return
|
|
318
|
+
const port = this.config.port ?? 8001;
|
|
319
|
+
const serviceRoot = `${protocol}://${address}:${port}/api/v1`;
|
|
320
|
+
return { serviceRoot, authRoot: serviceRoot + PRODUCT_VERSION_AUTH_SUFFIX };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private rootsIbmCloud(): { serviceRoot: string; authRoot: string } {
|
|
324
|
+
const { address, tenant, database, ssl } = this.config;
|
|
325
|
+
if (!address || !tenant || !database) {
|
|
326
|
+
throw new Error("'address', 'tenant' and 'database' must be provided to connect to TM1 > v12 in IBM Cloud");
|
|
327
|
+
}
|
|
328
|
+
if (!ssl) {
|
|
329
|
+
throw new Error("'ssl' must be true to connect to TM1 > v12 in IBM Cloud");
|
|
330
|
+
}
|
|
331
|
+
const t = encodeURIComponent(tenant);
|
|
332
|
+
const d = encodeURIComponent(database);
|
|
333
|
+
const serviceRoot = `https://${address}/api/${t}/v0/tm1/${d}`;
|
|
334
|
+
return { serviceRoot, authRoot: serviceRoot + PRODUCT_VERSION_AUTH_SUFFIX };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private rootsPaProxy(): { serviceRoot: string; authRoot: string } {
|
|
338
|
+
const { address, database, ssl } = this.config;
|
|
339
|
+
if (!address || !database) {
|
|
340
|
+
throw new Error("'address' and 'database' must be provided to connect to TM1 > v12 using PA Proxy");
|
|
341
|
+
}
|
|
342
|
+
const protocol = ssl ? 'https' : 'http';
|
|
343
|
+
const d = encodeURIComponent(database);
|
|
344
|
+
const serviceRoot = `${protocol}://${address}/tm1/${d}/api/v1`;
|
|
345
|
+
const authRoot = `${protocol}://${address}/login`;
|
|
346
|
+
return { serviceRoot, authRoot };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private rootsS2s(): { serviceRoot: string; authRoot: string } {
|
|
350
|
+
const { instance, database, ssl, port } = this.config;
|
|
351
|
+
if (!instance || !database) {
|
|
352
|
+
throw new Error("'instance' and 'database' arguments are required for v12 authentication with 'address'");
|
|
353
|
+
}
|
|
354
|
+
const protocol = ssl ? 'https' : 'http';
|
|
355
|
+
const address = this.config.address && this.config.address.length > 0
|
|
356
|
+
? this.config.address
|
|
357
|
+
: 'localhost';
|
|
358
|
+
const portPart = port != null ? `:${port}` : '';
|
|
359
|
+
const i = encodeURIComponent(instance);
|
|
360
|
+
const d = encodeURIComponent(database);
|
|
361
|
+
const serviceRoot = `${protocol}://${address}${portPart}/${i}/api/v1/Databases('${d}')`;
|
|
362
|
+
const authRoot = `${protocol}://${address}${portPart}/${i}/auth/v1/session`;
|
|
363
|
+
return { serviceRoot, authRoot };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private rootsFromBaseUrl(): { serviceRoot: string; authRoot: string } {
|
|
367
|
+
const base = this.config.baseUrl!;
|
|
368
|
+
if (this.config.address) {
|
|
369
|
+
throw new Error("Base URL and Address cannot be specified at the same time");
|
|
370
|
+
}
|
|
371
|
+
if (/api\/v1\/Databases/.test(base)) {
|
|
372
|
+
if (!this.config.authUrl) {
|
|
373
|
+
throw new Error("Auth_url missing — when connecting to planning analytics engine using base_url, you must specify a corresponding auth_url");
|
|
374
|
+
}
|
|
375
|
+
return { serviceRoot: base, authRoot: this.config.authUrl };
|
|
376
|
+
}
|
|
377
|
+
// Recognize baseUrl shapes documented in docs/connection-guide.md
|
|
378
|
+
// (TM1 11 IBM Cloud `/tm1/api/tm1`, TM1 12 PaaS/access-token `/v0/tm1/...`)
|
|
379
|
+
// and use them verbatim. Only fall through to /api/v1 suffixing when the
|
|
380
|
+
// URL clearly lacks any TM1 API path — matching tm1py's fallback.
|
|
381
|
+
const trimmed = base.replace(/\/+$/, '');
|
|
382
|
+
// Each alternative is $-anchored (after trailing-slash trim above) so the
|
|
383
|
+
// match intent is explicit: the URL already ends in a TM1 API suffix.
|
|
384
|
+
const hasApiSuffix = /\/api\/v1$|\/v0\/tm1\/[^/]+$|\/tm1\/api\/tm1$/.test(trimmed);
|
|
385
|
+
const serviceRoot = hasApiSuffix ? trimmed : `${trimmed}/api/v1`;
|
|
386
|
+
return { serviceRoot, authRoot: serviceRoot + PRODUCT_VERSION_AUTH_SUFFIX };
|
|
97
387
|
}
|
|
98
388
|
|
|
99
389
|
private setupInterceptors(): void {
|
|
100
390
|
// Request interceptor
|
|
101
391
|
this.axiosInstance.interceptors.request.use(
|
|
102
392
|
(config) => {
|
|
103
|
-
|
|
104
|
-
|
|
393
|
+
const cookieHeader = this.buildCookieHeader();
|
|
394
|
+
if (cookieHeader) {
|
|
395
|
+
config.headers['Cookie'] = cookieHeader;
|
|
105
396
|
}
|
|
106
397
|
if (this.sandboxName) {
|
|
107
398
|
config.headers['TM1-Sandbox'] = this.sandboxName;
|
|
@@ -113,29 +404,34 @@ export class RestService {
|
|
|
113
404
|
|
|
114
405
|
// Response interceptor with retry logic
|
|
115
406
|
this.axiosInstance.interceptors.response.use(
|
|
116
|
-
(response) =>
|
|
407
|
+
(response) => {
|
|
408
|
+
this.parseSetCookieHeaders(response.headers?.['set-cookie']);
|
|
409
|
+
return response;
|
|
410
|
+
},
|
|
117
411
|
async (error) => {
|
|
412
|
+
if (error.response) {
|
|
413
|
+
this.parseSetCookieHeaders(error.response.headers?.['set-cookie']);
|
|
414
|
+
}
|
|
118
415
|
const originalRequest = error.config;
|
|
119
416
|
|
|
120
417
|
// Handle timeout errors
|
|
121
|
-
if (error.code === 'ECONNABORTED' || error.message
|
|
418
|
+
if (error.code === 'ECONNABORTED' || error.message?.includes?.('timeout')) {
|
|
122
419
|
throw new TM1TimeoutException(`Request timeout: ${error.message}`);
|
|
123
420
|
}
|
|
124
421
|
|
|
125
|
-
// Handle authentication errors with retry
|
|
126
|
-
|
|
422
|
+
// Handle authentication errors with retry. Guarded by this.isConnected so a 401
|
|
423
|
+
// during disconnect()'s tm1.Close POST cannot recurse back into reAuthenticate().
|
|
424
|
+
if (error.response?.status === 401 && originalRequest && !originalRequest._retry
|
|
425
|
+
&& this.isConnected && this._reConnectOnSessionTimeout) {
|
|
127
426
|
originalRequest._retry = true;
|
|
128
427
|
|
|
129
428
|
try {
|
|
130
|
-
// Attempt re-authentication
|
|
131
429
|
await this.reAuthenticate();
|
|
132
430
|
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
431
|
+
// Stale values would defeat the rebuild by the request interceptor on replay
|
|
432
|
+
this.deleteHeaderCaseInsensitive(originalRequest.headers, 'Cookie');
|
|
433
|
+
this.deleteHeaderCaseInsensitive(originalRequest.headers, 'Authorization');
|
|
137
434
|
|
|
138
|
-
// Retry the original request
|
|
139
435
|
return this.axiosInstance(originalRequest);
|
|
140
436
|
} catch (reAuthError) {
|
|
141
437
|
// Re-authentication failed, throw original error
|
|
@@ -144,7 +440,7 @@ export class RestService {
|
|
|
144
440
|
}
|
|
145
441
|
|
|
146
442
|
// Handle connection errors with retry
|
|
147
|
-
if (this.shouldRetryRequest(error) && this.canRetryRequest(originalRequest)) {
|
|
443
|
+
if (originalRequest && this.shouldRetryRequest(error) && this.canRetryRequest(originalRequest)) {
|
|
148
444
|
return this.retryRequest(originalRequest);
|
|
149
445
|
}
|
|
150
446
|
|
|
@@ -163,6 +459,7 @@ export class RestService {
|
|
|
163
459
|
* Determine if a request should be retried
|
|
164
460
|
*/
|
|
165
461
|
private shouldRetryRequest(error: any): boolean {
|
|
462
|
+
if (!this._reConnectOnRemoteDisconnect) return false;
|
|
166
463
|
// Retry on network errors, timeouts, and 5xx server errors
|
|
167
464
|
return !error.response ||
|
|
168
465
|
error.code === 'ECONNRESET' ||
|
|
@@ -175,21 +472,45 @@ export class RestService {
|
|
|
175
472
|
* Check if a request can be retried
|
|
176
473
|
*/
|
|
177
474
|
private canRetryRequest(config: any): boolean {
|
|
475
|
+
if (config._idempotent === false) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
178
478
|
// Don't retry if already retried maximum times
|
|
179
479
|
config._retryCount = config._retryCount || 0;
|
|
180
|
-
return config._retryCount <
|
|
480
|
+
return config._retryCount < this._remoteDisconnectMaxRetries;
|
|
181
481
|
}
|
|
182
482
|
|
|
183
483
|
/**
|
|
184
|
-
* Retry a failed request with exponential backoff
|
|
484
|
+
* Retry a failed request with exponential backoff, reconnecting the
|
|
485
|
+
* session before replay. Mirrors tm1py's _handle_remote_disconnect,
|
|
486
|
+
* which calls _manage_http_adapter() + connect() prior to retrying
|
|
487
|
+
* so a dropped session is re-established rather than replayed dead.
|
|
185
488
|
*/
|
|
186
489
|
private async retryRequest(config: any): Promise<any> {
|
|
187
490
|
config._retryCount = config._retryCount || 0;
|
|
188
491
|
config._retryCount++;
|
|
189
492
|
|
|
190
|
-
const
|
|
493
|
+
const baseDelay = this._remoteDisconnectRetryDelay * Math.pow(2, config._retryCount - 1);
|
|
494
|
+
const retryDelay = Math.min(baseDelay, this._remoteDisconnectMaxDelay) * 1000;
|
|
191
495
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
192
496
|
|
|
497
|
+
// Re-establish session before replay. Clear sessionCookies first so
|
|
498
|
+
// connect() runs a full setupAuthentication (mirrors tm1py, whose
|
|
499
|
+
// connect() always re-runs _start_session / _set_session_id_cookie
|
|
500
|
+
// regardless of prior cookie state). Without this, a stale cookie
|
|
501
|
+
// from a server-invalidated session would be reused, the probe GET
|
|
502
|
+
// would 401, and the whole retry path would silently fail.
|
|
503
|
+
// Flip isConnected=false so the 401 re-auth branch cannot recurse
|
|
504
|
+
// through the probe.
|
|
505
|
+
this.sessionCookies.clear();
|
|
506
|
+
this.isConnected = false;
|
|
507
|
+
await this.connect();
|
|
508
|
+
|
|
509
|
+
// Drop stale Cookie/Authorization on the original config — the request
|
|
510
|
+
// interceptor rebuilds Cookie from sessionCookies on replay.
|
|
511
|
+
this.deleteHeaderCaseInsensitive(config.headers, 'Cookie');
|
|
512
|
+
this.deleteHeaderCaseInsensitive(config.headers, 'Authorization');
|
|
513
|
+
|
|
193
514
|
return this.axiosInstance(config);
|
|
194
515
|
}
|
|
195
516
|
|
|
@@ -207,24 +528,197 @@ export class RestService {
|
|
|
207
528
|
}
|
|
208
529
|
}
|
|
209
530
|
|
|
531
|
+
private *waitTimeGenerator(timeout: number): Generator<number> {
|
|
532
|
+
let delay = this._asyncPollingInitialDelay;
|
|
533
|
+
let elapsed = 0;
|
|
534
|
+
|
|
535
|
+
if (timeout) {
|
|
536
|
+
while (elapsed < timeout) {
|
|
537
|
+
yield delay;
|
|
538
|
+
elapsed += delay;
|
|
539
|
+
delay = Math.min(delay * this._asyncPollingBackoffFactor, this._asyncPollingMaxDelay);
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
while (true) {
|
|
543
|
+
yield delay;
|
|
544
|
+
delay = Math.min(delay * this._asyncPollingBackoffFactor, this._asyncPollingMaxDelay);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private async _executeSyncRequest(
|
|
550
|
+
method: string,
|
|
551
|
+
url: string,
|
|
552
|
+
data?: any,
|
|
553
|
+
timeout?: number,
|
|
554
|
+
idempotent?: boolean,
|
|
555
|
+
axiosExtras?: Partial<AxiosRequestConfig>
|
|
556
|
+
): Promise<AxiosResponse> {
|
|
557
|
+
const config: AxiosRequestConfig = {
|
|
558
|
+
method: method as AxiosRequestConfig['method'],
|
|
559
|
+
url,
|
|
560
|
+
data,
|
|
561
|
+
...axiosExtras
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
if (timeout !== undefined) {
|
|
565
|
+
config.timeout = timeout * 1000;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
(config as any)._idempotent = idempotent ?? false;
|
|
569
|
+
|
|
570
|
+
return this.axiosInstance.request(config);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async _executeAsyncRequest(
|
|
574
|
+
method: string,
|
|
575
|
+
url: string,
|
|
576
|
+
data?: any,
|
|
577
|
+
timeout?: number,
|
|
578
|
+
cancelAtTimeout?: boolean,
|
|
579
|
+
returnAsyncId?: boolean,
|
|
580
|
+
idempotent?: boolean,
|
|
581
|
+
axiosExtras?: Partial<AxiosRequestConfig>
|
|
582
|
+
): Promise<AxiosResponse | string> {
|
|
583
|
+
const preferValue = returnAsyncId ? 'respond-async' : 'respond-async,wait=55';
|
|
584
|
+
const config: AxiosRequestConfig = {
|
|
585
|
+
method: method as AxiosRequestConfig['method'],
|
|
586
|
+
url,
|
|
587
|
+
data,
|
|
588
|
+
...axiosExtras,
|
|
589
|
+
headers: {
|
|
590
|
+
...axiosExtras?.headers,
|
|
591
|
+
Prefer: preferValue
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
if (timeout !== undefined) {
|
|
596
|
+
config.timeout = timeout * 1000;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
(config as any)._idempotent = idempotent ?? false;
|
|
600
|
+
|
|
601
|
+
const response = await this.axiosInstance.request(config);
|
|
602
|
+
|
|
603
|
+
if (response.status !== 202) {
|
|
604
|
+
// Server completed synchronously. If the caller asked for an async
|
|
605
|
+
// ID (returnAsyncId: true) there is none to return — hand back the
|
|
606
|
+
// full response so the caller can inspect the result directly.
|
|
607
|
+
return response;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const location = response.headers['location'] || '';
|
|
611
|
+
const match = typeof location === 'string' ? location.match(/\('([^']+)'\)/) : null;
|
|
612
|
+
const asyncId = match ? match[1] : undefined;
|
|
613
|
+
|
|
614
|
+
if (!asyncId) {
|
|
615
|
+
throw new TM1RestException(
|
|
616
|
+
`Async request returned 202 but no valid async ID in Location header: ${location}`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (returnAsyncId) {
|
|
621
|
+
return asyncId;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return this._pollAsyncResponse(
|
|
625
|
+
asyncId,
|
|
626
|
+
timeout ?? this._timeout,
|
|
627
|
+
cancelAtTimeout ?? this._cancelAtTimeout
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private async _pollAsyncResponse(
|
|
632
|
+
asyncId: string,
|
|
633
|
+
timeout: number,
|
|
634
|
+
cancelAtTimeout: boolean
|
|
635
|
+
): Promise<AxiosResponse> {
|
|
636
|
+
for (const wait of this.waitTimeGenerator(timeout)) {
|
|
637
|
+
const response = await this.retrieve_async_response(asyncId);
|
|
638
|
+
|
|
639
|
+
if (response.status === 200 || response.status === 201) {
|
|
640
|
+
this.verifyAsyncResultHeader(response);
|
|
641
|
+
return response;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
await new Promise(resolve => setTimeout(resolve, wait * 1000));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (cancelAtTimeout) {
|
|
648
|
+
try {
|
|
649
|
+
await this.cancel_async_operation(asyncId);
|
|
650
|
+
} catch (cancelError) {
|
|
651
|
+
console.warn(`Failed to cancel async operation ${asyncId} at timeout:`, cancelError);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
throw new TM1TimeoutException(
|
|
656
|
+
`Async operation ${asyncId} timed out after ${timeout} seconds`,
|
|
657
|
+
timeout
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private async _request(
|
|
662
|
+
method: string,
|
|
663
|
+
url: string,
|
|
664
|
+
data?: any,
|
|
665
|
+
options?: RequestOptions
|
|
666
|
+
): Promise<AxiosResponse | string> {
|
|
667
|
+
const timeout = options?.timeout ?? this._timeout;
|
|
668
|
+
const cancelAtTimeout = options?.cancelAtTimeout ?? this._cancelAtTimeout;
|
|
669
|
+
const asyncMode = options?.returnAsyncId || (options?.asyncRequestsMode ?? this._asyncRequestsMode);
|
|
670
|
+
const verifyResponse = options?.verifyResponse ?? true;
|
|
671
|
+
const idempotent = options?.idempotent ?? false;
|
|
672
|
+
const {
|
|
673
|
+
asyncRequestsMode: _asyncModeOpt,
|
|
674
|
+
returnAsyncId,
|
|
675
|
+
cancelAtTimeout: _cancelAtTimeoutOpt,
|
|
676
|
+
idempotent: _idempotentOpt,
|
|
677
|
+
verifyResponse: _verifyResponseOpt,
|
|
678
|
+
timeout: _timeoutOpt,
|
|
679
|
+
...axiosExtras
|
|
680
|
+
} = options ?? {};
|
|
681
|
+
|
|
682
|
+
if (!verifyResponse && axiosExtras.validateStatus === undefined) {
|
|
683
|
+
axiosExtras.validateStatus = () => true;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (asyncMode) {
|
|
687
|
+
return this._executeAsyncRequest(
|
|
688
|
+
method,
|
|
689
|
+
url,
|
|
690
|
+
data,
|
|
691
|
+
timeout,
|
|
692
|
+
cancelAtTimeout,
|
|
693
|
+
returnAsyncId,
|
|
694
|
+
idempotent,
|
|
695
|
+
axiosExtras
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return this._executeSyncRequest(method, url, data, timeout, idempotent, axiosExtras);
|
|
700
|
+
}
|
|
701
|
+
|
|
210
702
|
public async connect(): Promise<void> {
|
|
211
703
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
704
|
+
if (this.getSessionCookieValue() === undefined) {
|
|
705
|
+
await this.setupAuthentication();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Mark probe non-idempotent so the response interceptor's retry
|
|
709
|
+
// branch skips it. Without this, a retry-triggered connect() whose
|
|
710
|
+
// probe also fails would spawn another connect(), recursively,
|
|
711
|
+
// bypassing remoteDisconnectMaxRetries (each probe has its own
|
|
712
|
+
// fresh _retryCount).
|
|
713
|
+
await this.axiosInstance.get(
|
|
714
|
+
'/Configuration/ServerName',
|
|
715
|
+
{ _idempotent: false } as AxiosRequestConfig
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
// Strip Authorization only if the session cookie is established; Bearer/API-key
|
|
719
|
+
// modes that never issue a cookie must keep Authorization to stay authenticated
|
|
720
|
+
if (this.getSessionCookieValue()) {
|
|
721
|
+
this.removeAuthorizationHeader();
|
|
228
722
|
}
|
|
229
723
|
|
|
230
724
|
this.isConnected = true;
|
|
@@ -234,39 +728,57 @@ export class RestService {
|
|
|
234
728
|
}
|
|
235
729
|
|
|
236
730
|
public async disconnect(): Promise<void> {
|
|
237
|
-
|
|
731
|
+
const shouldClose = this.isConnected;
|
|
732
|
+
// Flip isConnected first so a 401 on tm1.Close cannot trigger reAuthenticate recursion
|
|
733
|
+
this.isConnected = false;
|
|
734
|
+
if (shouldClose) {
|
|
238
735
|
try {
|
|
239
736
|
await this.axiosInstance.post('/ActiveSession/tm1.Close', {});
|
|
240
737
|
} catch (error) {
|
|
241
738
|
// Ignore errors during disconnect
|
|
242
739
|
}
|
|
243
|
-
this.isConnected = false;
|
|
244
|
-
this.sessionId = undefined;
|
|
245
740
|
}
|
|
741
|
+
this.sessionCookies.clear();
|
|
246
742
|
}
|
|
247
743
|
|
|
248
|
-
|
|
249
|
-
|
|
744
|
+
/**
|
|
745
|
+
* When `returnAsyncId: true`, the caller receives the async id string
|
|
746
|
+
* iff the server returns `202 Accepted`. If TM1 short-circuits with
|
|
747
|
+
* `200/201`, the full `AxiosResponse` is returned instead — the
|
|
748
|
+
* declared `Promise<string>` return type is a best-effort narrowing.
|
|
749
|
+
*/
|
|
750
|
+
public async get(url: string, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
|
|
751
|
+
public async get(url: string, options?: RequestOptions): Promise<AxiosResponse>;
|
|
752
|
+
public async get(url: string, options?: RequestOptions): Promise<AxiosResponse | string> {
|
|
753
|
+
return this._request('GET', url, undefined, { idempotent: true, ...options });
|
|
250
754
|
}
|
|
251
755
|
|
|
252
|
-
public async post(url: string, data
|
|
253
|
-
|
|
756
|
+
public async post(url: string, data: any, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
|
|
757
|
+
public async post(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse>;
|
|
758
|
+
public async post(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse | string> {
|
|
759
|
+
return this._request('POST', url, data, options);
|
|
254
760
|
}
|
|
255
761
|
|
|
256
|
-
public async patch(url: string, data
|
|
257
|
-
|
|
762
|
+
public async patch(url: string, data: any, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
|
|
763
|
+
public async patch(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse>;
|
|
764
|
+
public async patch(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse | string> {
|
|
765
|
+
return this._request('PATCH', url, data, options);
|
|
258
766
|
}
|
|
259
767
|
|
|
260
|
-
public async put(url: string, data
|
|
261
|
-
|
|
768
|
+
public async put(url: string, data: any, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
|
|
769
|
+
public async put(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse>;
|
|
770
|
+
public async put(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse | string> {
|
|
771
|
+
return this._request('PUT', url, data, options);
|
|
262
772
|
}
|
|
263
773
|
|
|
264
|
-
public async delete(url: string,
|
|
265
|
-
|
|
774
|
+
public async delete(url: string, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
|
|
775
|
+
public async delete(url: string, options?: RequestOptions): Promise<AxiosResponse>;
|
|
776
|
+
public async delete(url: string, options?: RequestOptions): Promise<AxiosResponse | string> {
|
|
777
|
+
return this._request('DELETE', url, undefined, options);
|
|
266
778
|
}
|
|
267
779
|
|
|
268
780
|
public getSessionId(): string | undefined {
|
|
269
|
-
return this.
|
|
781
|
+
return this.getSessionCookieValue();
|
|
270
782
|
}
|
|
271
783
|
|
|
272
784
|
public setSandbox(sandboxName?: string): void {
|
|
@@ -278,7 +790,10 @@ export class RestService {
|
|
|
278
790
|
}
|
|
279
791
|
|
|
280
792
|
public isLoggedIn(): boolean {
|
|
281
|
-
return this.isConnected &&
|
|
793
|
+
return this.isConnected && (
|
|
794
|
+
!!this.getSessionCookieValue() ||
|
|
795
|
+
!!this.axiosInstance.defaults.headers.common['Authorization']
|
|
796
|
+
);
|
|
282
797
|
}
|
|
283
798
|
|
|
284
799
|
public async getApiMetadata(): Promise<any> {
|
|
@@ -286,181 +801,347 @@ export class RestService {
|
|
|
286
801
|
return response.data;
|
|
287
802
|
}
|
|
288
803
|
|
|
804
|
+
// =========================================================================
|
|
805
|
+
// Authentication helpers — mirror tm1py's _build_authorization_token*,
|
|
806
|
+
// _generate_*_access_token, and _start_session flows
|
|
807
|
+
// =========================================================================
|
|
808
|
+
|
|
289
809
|
/**
|
|
290
|
-
*
|
|
810
|
+
* Build an httpsAgent option that skips TLS verification when verify is false.
|
|
291
811
|
*/
|
|
292
|
-
private
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
812
|
+
private static insecureAgentOption(
|
|
813
|
+
verify?: boolean | string
|
|
814
|
+
): { httpsAgent: https.Agent } | Record<string, never> {
|
|
815
|
+
return verify === false
|
|
816
|
+
? { httpsAgent: new https.Agent({ rejectUnauthorized: false }) }
|
|
817
|
+
: {};
|
|
818
|
+
}
|
|
298
819
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
820
|
+
/**
|
|
821
|
+
* Normalise a Set-Cookie header value (string | string[] | undefined) into a string[].
|
|
822
|
+
*/
|
|
823
|
+
private static normaliseSetCookie(raw: string | string[] | undefined): string[] {
|
|
824
|
+
if (!raw) return [];
|
|
825
|
+
return Array.isArray(raw) ? raw : [raw];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Extract a named cookie value from raw Set-Cookie headers.
|
|
830
|
+
*/
|
|
831
|
+
private static extractCookieValue(
|
|
832
|
+
raw: string | string[] | undefined,
|
|
833
|
+
name: string
|
|
834
|
+
): string | undefined {
|
|
835
|
+
const prefix = name + '=';
|
|
836
|
+
for (const header of RestService.normaliseSetCookie(raw)) {
|
|
837
|
+
const segment = header.split(';')[0];
|
|
838
|
+
if (segment.startsWith(prefix)) {
|
|
839
|
+
return segment.slice(prefix.length);
|
|
307
840
|
}
|
|
308
|
-
return;
|
|
309
841
|
}
|
|
842
|
+
return undefined;
|
|
843
|
+
}
|
|
310
844
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
845
|
+
/**
|
|
846
|
+
* Build Basic Authorization header.
|
|
847
|
+
* Mirrors tm1py's _build_authorization_token_basic.
|
|
848
|
+
*/
|
|
849
|
+
private static _buildAuthorizationTokenBasic(user: string, password: string): string {
|
|
850
|
+
return 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
851
|
+
}
|
|
316
852
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
853
|
+
/**
|
|
854
|
+
* Build CAMNamespace Authorization header.
|
|
855
|
+
* Mirrors tm1py's _build_authorization_token_cam (non-gateway path).
|
|
856
|
+
*/
|
|
857
|
+
private static _buildAuthorizationTokenCam(
|
|
858
|
+
user: string,
|
|
859
|
+
password: string,
|
|
860
|
+
namespace: string
|
|
861
|
+
): string {
|
|
862
|
+
return 'CAMNamespace ' + Buffer.from(`${user}:${password}:${namespace}`).toString('base64');
|
|
863
|
+
}
|
|
322
864
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
865
|
+
/**
|
|
866
|
+
* Build CAMPassport Authorization token via gateway SSO.
|
|
867
|
+
* Mirrors tm1py's _build_authorization_token_cam (gateway path).
|
|
868
|
+
* Makes a GET request to the gateway URL with CAMNamespace as a query
|
|
869
|
+
* parameter and extracts the cam_passport cookie from the response.
|
|
870
|
+
*
|
|
871
|
+
* Note: tm1py uses HttpNegotiateAuth (NTLM/Kerberos) for gateway requests,
|
|
872
|
+
* which is Windows-only. This implementation sends a plain GET and relies on
|
|
873
|
+
* the gateway being accessible without NTLM. For environments requiring NTLM,
|
|
874
|
+
* pass a pre-obtained cam_passport via config.camPassport instead.
|
|
875
|
+
*/
|
|
876
|
+
private static async _buildAuthorizationTokenCamSso(
|
|
877
|
+
gateway: string,
|
|
878
|
+
namespace: string,
|
|
879
|
+
verify?: boolean | string
|
|
880
|
+
): Promise<string> {
|
|
881
|
+
const response = await axios.get(gateway, {
|
|
882
|
+
params: { CAMNamespace: namespace },
|
|
883
|
+
...RestService.insecureAgentOption(verify)
|
|
884
|
+
});
|
|
885
|
+
if (response.status !== 200) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
'Failed to authenticate through CAM. Expected status_code 200, received status_code: ' +
|
|
888
|
+
response.status
|
|
889
|
+
);
|
|
327
890
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (this.config.namespace) {
|
|
336
|
-
this.axiosInstance.defaults.headers.common['TM1-Namespace'] = this.config.namespace;
|
|
337
|
-
}
|
|
338
|
-
return;
|
|
891
|
+
const passport = RestService.extractCookieValue(
|
|
892
|
+
response.headers['set-cookie'], 'cam_passport'
|
|
893
|
+
);
|
|
894
|
+
if (!passport) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
"Failed to authenticate through CAM. HTTP response does not contain 'cam_passport' cookie"
|
|
897
|
+
);
|
|
339
898
|
}
|
|
340
|
-
|
|
341
|
-
throw new Error('No valid authentication configuration provided');
|
|
899
|
+
return 'CAMPassport ' + passport;
|
|
342
900
|
}
|
|
343
901
|
|
|
344
902
|
/**
|
|
345
|
-
*
|
|
903
|
+
* Generate IBM IAM Cloud access token.
|
|
904
|
+
* Mirrors tm1py's _generate_ibm_iam_cloud_access_token.
|
|
346
905
|
*/
|
|
347
|
-
private async
|
|
348
|
-
|
|
349
|
-
|
|
906
|
+
private async _generateIbmIamCloudAccessToken(): Promise<string> {
|
|
907
|
+
const { iamUrl, apiKey } = this.config;
|
|
908
|
+
if (!iamUrl || !apiKey) {
|
|
909
|
+
throw new Error("'iamUrl' and 'apiKey' must be provided to generate access token from IBM Cloud");
|
|
350
910
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
if (authResponse.data && authResponse.data.sessionId) {
|
|
365
|
-
this.sessionId = authResponse.data.sessionId;
|
|
366
|
-
this.axiosInstance.defaults.headers.common['TM1SessionId'] = this.sessionId;
|
|
367
|
-
} else {
|
|
368
|
-
throw new Error('CAM authentication failed: No session ID returned');
|
|
369
|
-
}
|
|
370
|
-
} catch (error) {
|
|
371
|
-
throw new Error(`CAM authentication failed: ${error}`);
|
|
911
|
+
const payload = `grant_type=urn%3Aibm%3Aparams%3Aoauth%3Agrant-type%3Aapikey&apikey=${encodeURIComponent(apiKey)}`;
|
|
912
|
+
const headers = {
|
|
913
|
+
'Accept': 'application/json',
|
|
914
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
915
|
+
};
|
|
916
|
+
const response = await axios.post(iamUrl, payload, {
|
|
917
|
+
headers,
|
|
918
|
+
...RestService.insecureAgentOption(this.config.verify)
|
|
919
|
+
});
|
|
920
|
+
if (!response.data?.access_token) {
|
|
921
|
+
throw new Error(`Failed to generate access_token from URL: '${iamUrl}'`);
|
|
372
922
|
}
|
|
923
|
+
return response.data.access_token;
|
|
373
924
|
}
|
|
374
925
|
|
|
375
926
|
/**
|
|
376
|
-
*
|
|
927
|
+
* Generate CPD (Cloud Pak for Data) access token.
|
|
928
|
+
* Mirrors tm1py's _generate_cpd_access_token.
|
|
377
929
|
*/
|
|
378
|
-
private async
|
|
379
|
-
|
|
380
|
-
|
|
930
|
+
private async _generateCpdAccessToken(
|
|
931
|
+
credentials: { username: string; password: string }
|
|
932
|
+
): Promise<string> {
|
|
933
|
+
const { cpdUrl } = this.config;
|
|
934
|
+
if (!cpdUrl) {
|
|
935
|
+
throw new Error("'cpdUrl' must be provided to authenticate via CPD/Cloud Pak for Data");
|
|
381
936
|
}
|
|
937
|
+
const url = `${cpdUrl}/v1/preauth/signin`;
|
|
938
|
+
const headers = { 'Content-Type': 'application/json;charset=UTF-8' };
|
|
939
|
+
const response = await axios.post(url, credentials, {
|
|
940
|
+
headers,
|
|
941
|
+
...RestService.insecureAgentOption(this.config.verify)
|
|
942
|
+
});
|
|
943
|
+
if (!response.data?.token) {
|
|
944
|
+
throw new Error(`Failed to generate CPD access token from URL: '${url}'`);
|
|
945
|
+
}
|
|
946
|
+
return response.data.token;
|
|
947
|
+
}
|
|
382
948
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
this.sessionId = authResponse.data.sessionId;
|
|
400
|
-
this.axiosInstance.defaults.headers.common['TM1SessionId'] = this.sessionId;
|
|
401
|
-
} else {
|
|
402
|
-
throw new Error('CAM SSO authentication failed: No token or session ID returned');
|
|
403
|
-
}
|
|
404
|
-
} catch (error) {
|
|
405
|
-
throw new Error(`CAM SSO authentication failed: ${error}`);
|
|
949
|
+
/**
|
|
950
|
+
* Authenticate with PA Proxy using a CPD JWT token.
|
|
951
|
+
* Mirrors tm1py's PA_PROXY flow in _start_session.
|
|
952
|
+
*/
|
|
953
|
+
private async _authenticateWithPaProxy(jwt: string): Promise<void> {
|
|
954
|
+
const authRoot = this.resolveRoots().authRoot;
|
|
955
|
+
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
956
|
+
const payload = `jwt=${jwt}`;
|
|
957
|
+
const response = await axios.post(authRoot, payload, {
|
|
958
|
+
headers,
|
|
959
|
+
...RestService.insecureAgentOption(this.config.verify)
|
|
960
|
+
});
|
|
961
|
+
const setCookie = response.headers['set-cookie'];
|
|
962
|
+
const csrfValue = RestService.extractCookieValue(setCookie, 'ba-sso-csrf');
|
|
963
|
+
if (csrfValue) {
|
|
964
|
+
this.axiosInstance.defaults.headers.common['ba-sso-authenticity'] = csrfValue;
|
|
406
965
|
}
|
|
966
|
+
this.parseSetCookieHeaders(setCookie);
|
|
407
967
|
}
|
|
408
968
|
|
|
409
969
|
/**
|
|
410
|
-
*
|
|
970
|
+
* Authenticate Service-to-Service (v12).
|
|
971
|
+
* Mirrors tm1py's SERVICE_TO_SERVICE flow in _start_session:
|
|
972
|
+
* Uses Basic auth with applicationClientId:applicationClientSecret,
|
|
973
|
+
* then POSTs {"User": user} to the auth endpoint.
|
|
411
974
|
*/
|
|
412
|
-
private async
|
|
413
|
-
|
|
414
|
-
|
|
975
|
+
private async _authenticateServiceToService(): Promise<void> {
|
|
976
|
+
const { applicationClientId, applicationClientSecret, user } = this.config;
|
|
977
|
+
if (!applicationClientId || !applicationClientSecret) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
'Service-to-Service authentication requires applicationClientId and applicationClientSecret'
|
|
980
|
+
);
|
|
415
981
|
}
|
|
416
982
|
|
|
417
|
-
|
|
418
|
-
|
|
983
|
+
// Guard: v11 and plain baseUrl topologies resolve authRoot to a metadata
|
|
984
|
+
// probe URL, not a token endpoint. Require explicit authUrl in those cases.
|
|
985
|
+
if (!this.config.authUrl) {
|
|
986
|
+
const topo = this.determineTopology();
|
|
987
|
+
const baseUrlIsV12 = topo === 'base_url'
|
|
988
|
+
&& /api\/v1\/Databases/.test(this.config.baseUrl ?? '');
|
|
989
|
+
if (topo === 'v11' || (topo === 'base_url' && !baseUrlIsV12)) {
|
|
990
|
+
throw new Error(
|
|
991
|
+
"'authUrl' is required for Service-to-Service authentication on v11 topology"
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
419
995
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
};
|
|
996
|
+
const authRoot = this.config.authUrl || this.resolveRoots().authRoot;
|
|
997
|
+
const basicAuth = Buffer.from(
|
|
998
|
+
`${applicationClientId}:${applicationClientSecret}`
|
|
999
|
+
).toString('base64');
|
|
425
1000
|
|
|
426
|
-
|
|
427
|
-
headers: {
|
|
428
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
429
|
-
}
|
|
430
|
-
});
|
|
1001
|
+
this.axiosInstance.defaults.headers.common['Authorization'] = `Basic ${basicAuth}`;
|
|
431
1002
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1003
|
+
const response = await axios.post(
|
|
1004
|
+
authRoot,
|
|
1005
|
+
JSON.stringify({ User: user }),
|
|
1006
|
+
{
|
|
1007
|
+
headers: {
|
|
1008
|
+
...RestService.HEADERS,
|
|
1009
|
+
'Authorization': `Basic ${basicAuth}`
|
|
1010
|
+
},
|
|
1011
|
+
...RestService.insecureAgentOption(this.config.verify)
|
|
436
1012
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
this.parseSetCookieHeaders(response.headers['set-cookie']);
|
|
440
1016
|
}
|
|
441
1017
|
|
|
442
1018
|
/**
|
|
443
|
-
*
|
|
1019
|
+
* Determine the authentication mode from config.
|
|
1020
|
+
* Mirrors tm1py's _determine_auth_mode, using the URL topology as the
|
|
1021
|
+
* primary discriminator for v12 modes.
|
|
444
1022
|
*/
|
|
445
1023
|
public getAuthenticationMode(): AuthenticationMode {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
AuthenticationMode.IBM_CLOUD_API_KEY
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
1024
|
+
const topo = this.determineTopology();
|
|
1025
|
+
const c = this.config;
|
|
1026
|
+
|
|
1027
|
+
switch (topo) {
|
|
1028
|
+
case 'ibm_cloud':
|
|
1029
|
+
return AuthenticationMode.IBM_CLOUD_API_KEY;
|
|
1030
|
+
case 'pa_proxy':
|
|
1031
|
+
return AuthenticationMode.PA_PROXY;
|
|
1032
|
+
case 's2s':
|
|
1033
|
+
return AuthenticationMode.SERVICE_TO_SERVICE;
|
|
1034
|
+
|
|
1035
|
+
case 'v11':
|
|
1036
|
+
case 'base_url':
|
|
1037
|
+
default: {
|
|
1038
|
+
// v11 / base_url: check auth-specific config flags
|
|
1039
|
+
if (c.accessToken) return AuthenticationMode.ACCESS_TOKEN;
|
|
1040
|
+
if (c.apiKey) return AuthenticationMode.BASIC_API_KEY;
|
|
1041
|
+
if (c.applicationClientId && c.applicationClientSecret) {
|
|
1042
|
+
return AuthenticationMode.SERVICE_TO_SERVICE;
|
|
1043
|
+
}
|
|
1044
|
+
if (c.camPassport) return AuthenticationMode.CAM;
|
|
1045
|
+
if (c.gateway && c.namespace) return AuthenticationMode.CAM_SSO;
|
|
1046
|
+
if (c.integratedLogin) return AuthenticationMode.WIA;
|
|
1047
|
+
if (c.namespace) return AuthenticationMode.CAM;
|
|
1048
|
+
return AuthenticationMode.BASIC;
|
|
1049
|
+
}
|
|
459
1050
|
}
|
|
460
|
-
|
|
461
|
-
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Set up authentication based on configuration.
|
|
1055
|
+
* Mirrors tm1py's _start_session routing.
|
|
1056
|
+
*/
|
|
1057
|
+
private async setupAuthentication(): Promise<void> {
|
|
1058
|
+
const authMode = this.getAuthenticationMode();
|
|
1059
|
+
const password = this.config.decodeB64 && this.config.password
|
|
1060
|
+
? RestService.b64_decode_password(this.config.password)
|
|
1061
|
+
: this.config.password;
|
|
1062
|
+
|
|
1063
|
+
switch (authMode) {
|
|
1064
|
+
case AuthenticationMode.ACCESS_TOKEN:
|
|
1065
|
+
this.axiosInstance.defaults.headers.common['Authorization'] =
|
|
1066
|
+
`Bearer ${this.config.accessToken}`;
|
|
1067
|
+
break;
|
|
1068
|
+
|
|
1069
|
+
case AuthenticationMode.BASIC_API_KEY:
|
|
1070
|
+
if (this.config.user === 'apikey') {
|
|
1071
|
+
this.axiosInstance.defaults.headers.common['Authorization'] =
|
|
1072
|
+
RestService._buildAuthorizationTokenBasic('apikey', this.config.apiKey!);
|
|
1073
|
+
} else {
|
|
1074
|
+
this.axiosInstance.defaults.headers.common['API-Key'] = this.config.apiKey!;
|
|
1075
|
+
}
|
|
1076
|
+
break;
|
|
1077
|
+
|
|
1078
|
+
case AuthenticationMode.IBM_CLOUD_API_KEY: {
|
|
1079
|
+
const accessToken = await this._generateIbmIamCloudAccessToken();
|
|
1080
|
+
this.axiosInstance.defaults.headers.common['Authorization'] =
|
|
1081
|
+
`Bearer ${accessToken}`;
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
case AuthenticationMode.PA_PROXY: {
|
|
1086
|
+
if (!this.config.user || !password) {
|
|
1087
|
+
throw new Error('PA Proxy authentication requires user and password');
|
|
1088
|
+
}
|
|
1089
|
+
const jwt = await this._generateCpdAccessToken({
|
|
1090
|
+
username: this.config.user,
|
|
1091
|
+
password
|
|
1092
|
+
});
|
|
1093
|
+
await this._authenticateWithPaProxy(jwt);
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
case AuthenticationMode.SERVICE_TO_SERVICE:
|
|
1098
|
+
await this._authenticateServiceToService();
|
|
1099
|
+
break;
|
|
1100
|
+
|
|
1101
|
+
case AuthenticationMode.CAM_SSO: {
|
|
1102
|
+
// CAM_SSO is only reached when gateway is set (see getAuthenticationMode)
|
|
1103
|
+
const token = await RestService._buildAuthorizationTokenCamSso(
|
|
1104
|
+
this.config.gateway!,
|
|
1105
|
+
this.config.namespace!,
|
|
1106
|
+
this.config.verify
|
|
1107
|
+
);
|
|
1108
|
+
this.axiosInstance.defaults.headers.common['Authorization'] = token;
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
case AuthenticationMode.CAM: {
|
|
1113
|
+
if (this.config.camPassport) {
|
|
1114
|
+
this.axiosInstance.defaults.headers.common['Authorization'] =
|
|
1115
|
+
'CAMPassport ' + this.config.camPassport;
|
|
1116
|
+
} else if (this.config.namespace && this.config.user && password) {
|
|
1117
|
+
this.axiosInstance.defaults.headers.common['Authorization'] =
|
|
1118
|
+
RestService._buildAuthorizationTokenCam(
|
|
1119
|
+
this.config.user, password, this.config.namespace
|
|
1120
|
+
);
|
|
1121
|
+
} else {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
'CAM authentication requires either camPassport or user/password/namespace'
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
case AuthenticationMode.WIA:
|
|
1130
|
+
throw new Error(
|
|
1131
|
+
'Windows Integrated Authentication (WIA) is not supported in Node.js. ' +
|
|
1132
|
+
'Use CAM or Basic authentication instead.'
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
case AuthenticationMode.BASIC:
|
|
1136
|
+
default: {
|
|
1137
|
+
if (!this.config.user || !password) {
|
|
1138
|
+
throw new Error('No valid authentication configuration provided');
|
|
1139
|
+
}
|
|
1140
|
+
this.axiosInstance.defaults.headers.common['Authorization'] =
|
|
1141
|
+
RestService._buildAuthorizationTokenBasic(this.config.user, password);
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
462
1144
|
}
|
|
463
|
-
return AuthenticationMode.BASIC;
|
|
464
1145
|
}
|
|
465
1146
|
|
|
466
1147
|
/**
|
|
@@ -513,10 +1194,10 @@ export class RestService {
|
|
|
513
1194
|
} {
|
|
514
1195
|
return {
|
|
515
1196
|
isConnected: this.isConnected,
|
|
516
|
-
sessionId: this.
|
|
1197
|
+
sessionId: this.getSessionCookieValue(),
|
|
517
1198
|
authMode: this.getAuthenticationMode(),
|
|
518
1199
|
baseUrl: this.buildBaseUrl(),
|
|
519
|
-
timeout:
|
|
1200
|
+
timeout: this._timeout,
|
|
520
1201
|
sandbox: this.sandboxName
|
|
521
1202
|
};
|
|
522
1203
|
}
|
|
@@ -618,14 +1299,17 @@ export class RestService {
|
|
|
618
1299
|
* Check if currently connected to TM1
|
|
619
1300
|
*/
|
|
620
1301
|
public is_connected(): boolean {
|
|
621
|
-
return this.isConnected &&
|
|
1302
|
+
return this.isConnected && (
|
|
1303
|
+
!!this.getSessionCookieValue() ||
|
|
1304
|
+
!!this.axiosInstance.defaults.headers.common['Authorization']
|
|
1305
|
+
);
|
|
622
1306
|
}
|
|
623
1307
|
|
|
624
1308
|
/**
|
|
625
1309
|
* Get the current session ID
|
|
626
1310
|
*/
|
|
627
1311
|
public session_id(): string | undefined {
|
|
628
|
-
return this.
|
|
1312
|
+
return this.getSessionCookieValue();
|
|
629
1313
|
}
|
|
630
1314
|
|
|
631
1315
|
/**
|
|
@@ -652,55 +1336,71 @@ export class RestService {
|
|
|
652
1336
|
}
|
|
653
1337
|
|
|
654
1338
|
/**
|
|
655
|
-
*
|
|
1339
|
+
* Fetch the active user's group names as a CaseAndSpaceInsensitiveSet so
|
|
1340
|
+
* membership tests are case- and whitespace-insensitive (mirrors tm1py).
|
|
1341
|
+
* Concurrent callers (e.g. Promise.all([is_admin(), is_data_admin(), ...]))
|
|
1342
|
+
* coalesce onto a single in-flight request.
|
|
1343
|
+
*/
|
|
1344
|
+
private fetchActiveUserGroupNames(): Promise<CaseAndSpaceInsensitiveSet> {
|
|
1345
|
+
if (this._activeUserGroupsPromise) return this._activeUserGroupsPromise;
|
|
1346
|
+
|
|
1347
|
+
const clear = () => { this._activeUserGroupsPromise = undefined; };
|
|
1348
|
+
const fetch = this.get('/ActiveUser/Groups').then(
|
|
1349
|
+
(response) => {
|
|
1350
|
+
clear();
|
|
1351
|
+
const set = new CaseAndSpaceInsensitiveSet();
|
|
1352
|
+
const value = response.data?.value ?? [];
|
|
1353
|
+
for (const group of value) {
|
|
1354
|
+
if (group?.Name) set.add(group.Name);
|
|
1355
|
+
}
|
|
1356
|
+
return set;
|
|
1357
|
+
},
|
|
1358
|
+
(err) => { clear(); throw err; }
|
|
1359
|
+
);
|
|
1360
|
+
|
|
1361
|
+
this._activeUserGroupsPromise = fetch;
|
|
1362
|
+
return fetch;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Check if current user is admin. Result is cached after the first
|
|
1367
|
+
* computation, and pre-populated when the configured user is ADMIN.
|
|
656
1368
|
*/
|
|
657
1369
|
public async is_admin(): Promise<boolean> {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
} catch (error) {
|
|
663
|
-
return false;
|
|
664
|
-
}
|
|
1370
|
+
if (this._isAdmin !== undefined) return this._isAdmin;
|
|
1371
|
+
const groups = await this.fetchActiveUserGroupNames();
|
|
1372
|
+
this._isAdmin = groups.has('ADMIN');
|
|
1373
|
+
return this._isAdmin;
|
|
665
1374
|
}
|
|
666
1375
|
|
|
667
1376
|
/**
|
|
668
|
-
* Check if current user is data admin
|
|
1377
|
+
* Check if current user is data admin (member of Admin or DataAdmin).
|
|
669
1378
|
*/
|
|
670
1379
|
public async is_data_admin(): Promise<boolean> {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
} catch (error) {
|
|
676
|
-
return false;
|
|
677
|
-
}
|
|
1380
|
+
if (this._isDataAdmin !== undefined) return this._isDataAdmin;
|
|
1381
|
+
const groups = await this.fetchActiveUserGroupNames();
|
|
1382
|
+
this._isDataAdmin = groups.has('Admin') || groups.has('DataAdmin');
|
|
1383
|
+
return this._isDataAdmin;
|
|
678
1384
|
}
|
|
679
1385
|
|
|
680
1386
|
/**
|
|
681
|
-
* Check if current user is ops admin
|
|
1387
|
+
* Check if current user is ops admin (member of Admin or OperationsAdmin).
|
|
682
1388
|
*/
|
|
683
1389
|
public async is_ops_admin(): Promise<boolean> {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
} catch (error) {
|
|
689
|
-
return false;
|
|
690
|
-
}
|
|
1390
|
+
if (this._isOpsAdmin !== undefined) return this._isOpsAdmin;
|
|
1391
|
+
const groups = await this.fetchActiveUserGroupNames();
|
|
1392
|
+
this._isOpsAdmin = groups.has('Admin') || groups.has('OperationsAdmin');
|
|
1393
|
+
return this._isOpsAdmin;
|
|
691
1394
|
}
|
|
692
1395
|
|
|
693
1396
|
/**
|
|
694
|
-
* Check if current user is security admin
|
|
1397
|
+
* Check if current user is security admin (member of Admin or SecurityAdmin).
|
|
695
1398
|
*/
|
|
696
1399
|
public async is_security_admin(): Promise<boolean> {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
} catch (error) {
|
|
702
|
-
return false;
|
|
703
|
-
}
|
|
1400
|
+
if (this._isSecurityAdmin !== undefined) return this._isSecurityAdmin;
|
|
1401
|
+
const groups = await this.fetchActiveUserGroupNames();
|
|
1402
|
+
this._isSecurityAdmin = groups.has('Admin') || groups.has('SecurityAdmin');
|
|
1403
|
+
return this._isSecurityAdmin;
|
|
704
1404
|
}
|
|
705
1405
|
|
|
706
1406
|
/**
|
|
@@ -734,82 +1434,124 @@ export class RestService {
|
|
|
734
1434
|
return headers;
|
|
735
1435
|
}
|
|
736
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* Insert `tm1.compact=v0` into the Accept header (after the
|
|
1439
|
+
* `application/json` segment) and return the previous header value.
|
|
1440
|
+
* Mirrors tm1py's add_compact_json_header.
|
|
1441
|
+
*/
|
|
1442
|
+
public add_compact_json_header(): string {
|
|
1443
|
+
const original = (this.axiosInstance.defaults.headers.common['Accept'] as string | undefined) ?? '';
|
|
1444
|
+
const parts = original.split(';');
|
|
1445
|
+
// Insertion point matters: must come immediately after `application/json`
|
|
1446
|
+
parts.splice(1, 0, 'tm1.compact=v0');
|
|
1447
|
+
this.axiosInstance.defaults.headers.common['Accept'] = parts.join(';');
|
|
1448
|
+
return original;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Decode a Base64-encoded password to its UTF-8 plaintext form
|
|
1453
|
+
* (mirrors tm1py's b64_decode_password).
|
|
1454
|
+
*/
|
|
1455
|
+
public static b64_decode_password(encryptedPassword: string): string {
|
|
1456
|
+
return Buffer.from(encryptedPassword, 'base64').toString('utf-8');
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Coerce a boolean/number/string config value to a boolean. Strings
|
|
1461
|
+
* are stripped of whitespace and lowercased before comparison with
|
|
1462
|
+
* `'true'` (mirrors tm1py's translate_to_boolean).
|
|
1463
|
+
*/
|
|
1464
|
+
public static translate_to_boolean(value: unknown): boolean {
|
|
1465
|
+
if (typeof value === 'boolean') return value;
|
|
1466
|
+
if (typeof value === 'number') return Boolean(value);
|
|
1467
|
+
if (typeof value === 'string') {
|
|
1468
|
+
return value.replace(/\s+/g, '').toLowerCase() === 'true';
|
|
1469
|
+
}
|
|
1470
|
+
throw new Error(
|
|
1471
|
+
`Invalid argument: '${String(value)}'. Must be type 'boolean', 'number', or 'string'`
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
737
1475
|
/**
|
|
738
1476
|
* Cancel an async operation by ID
|
|
739
1477
|
*/
|
|
740
1478
|
public async cancel_async_operation(async_id: string): Promise<void> {
|
|
741
|
-
|
|
742
|
-
await this.post(`/AsyncOperations('${async_id}')/tm1.Cancel`);
|
|
743
|
-
} catch (error) {
|
|
744
|
-
throw new TM1RestException(`Failed to cancel async operation ${async_id}: ${error}`);
|
|
745
|
-
}
|
|
1479
|
+
await this.delete(formatUrl("/_async('{}')", async_id), { asyncRequestsMode: false });
|
|
746
1480
|
}
|
|
747
1481
|
|
|
748
1482
|
/**
|
|
749
1483
|
* Retrieve async operation response
|
|
750
1484
|
*/
|
|
751
|
-
public async retrieve_async_response(async_id: string): Promise<
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
const status = error?.status ?? error?.response?.status;
|
|
764
|
-
throw new TM1RestException(
|
|
765
|
-
`Failed to retrieve async response ${async_id}: ${error}`,
|
|
766
|
-
status,
|
|
767
|
-
error?.response
|
|
768
|
-
);
|
|
769
|
-
}
|
|
1485
|
+
public async retrieve_async_response(async_id: string): Promise<AxiosResponse> {
|
|
1486
|
+
// tm1py's retrieve_async_response returns the raw response without
|
|
1487
|
+
// raising on non-2xx because its caller (_poll_async_response) gates
|
|
1488
|
+
// on status_code in [200, 201]. Mirror that: accept all statuses so
|
|
1489
|
+
// transient 404s (resource not yet materialized) or 202s (still
|
|
1490
|
+
// running) flow through to the polling loop rather than aborting it.
|
|
1491
|
+
return this.get(formatUrl("/_async('{}')", async_id), {
|
|
1492
|
+
asyncRequestsMode: false,
|
|
1493
|
+
verifyResponse: false,
|
|
1494
|
+
validateStatus: () => true
|
|
1495
|
+
}) as Promise<AxiosResponse>;
|
|
770
1496
|
}
|
|
771
1497
|
|
|
772
1498
|
/**
|
|
773
|
-
*
|
|
1499
|
+
* TM1 v12 returns completed async results with HTTP 200 and encodes
|
|
1500
|
+
* the true operation status in the `asyncresult` header (e.g.
|
|
1501
|
+
* "500 Internal Server Error"). Mirror tm1py's
|
|
1502
|
+
* `_transform_async_response` by throwing on any embedded non-2xx
|
|
1503
|
+
* status so callers are not handed a 500 as "success".
|
|
774
1504
|
*/
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1505
|
+
private verifyAsyncResultHeader(response: AxiosResponse): void {
|
|
1506
|
+
const headerValue = response.headers?.['asyncresult'];
|
|
1507
|
+
if (typeof headerValue !== 'string') return;
|
|
1508
|
+
const embeddedStatus = parseInt(headerValue.trim().split(/\s+/)[0], 10);
|
|
1509
|
+
if (Number.isNaN(embeddedStatus)) return;
|
|
1510
|
+
if (embeddedStatus >= 200 && embeddedStatus < 300) return;
|
|
1511
|
+
throw new TM1RestException(
|
|
1512
|
+
`Async operation failed with status ${headerValue}`,
|
|
1513
|
+
embeddedStatus,
|
|
1514
|
+
response
|
|
1515
|
+
);
|
|
782
1516
|
}
|
|
783
1517
|
|
|
784
1518
|
/**
|
|
785
|
-
* Wait for async operation to complete
|
|
1519
|
+
* Wait for async operation to complete using a fixed polling cadence.
|
|
1520
|
+
*
|
|
1521
|
+
* Unlike the internal dispatcher's {@link waitTimeGenerator} (capped
|
|
1522
|
+
* exponential backoff), this public helper polls every
|
|
1523
|
+
* {@link poll_interval_seconds} seconds so existing callers who tuned
|
|
1524
|
+
* the cadence keep their original behavior.
|
|
786
1525
|
*/
|
|
787
1526
|
public async wait_for_async_operation(
|
|
788
1527
|
async_id: string,
|
|
789
1528
|
timeout_seconds: number = 300,
|
|
790
|
-
poll_interval_seconds: number = 1
|
|
1529
|
+
poll_interval_seconds: number = 1,
|
|
1530
|
+
cancel_at_timeout: boolean = false
|
|
791
1531
|
): Promise<any> {
|
|
792
|
-
const
|
|
793
|
-
const timeout_ms = timeout_seconds * 1000;
|
|
794
|
-
const poll_interval_ms = poll_interval_seconds * 1000;
|
|
1532
|
+
const deadline = Date.now() + timeout_seconds * 1000;
|
|
795
1533
|
|
|
796
|
-
while (Date.now()
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
return
|
|
1534
|
+
while (Date.now() < deadline) {
|
|
1535
|
+
const response = await this.retrieve_async_response(async_id);
|
|
1536
|
+
if (response.status === 200 || response.status === 201) {
|
|
1537
|
+
this.verifyAsyncResultHeader(response);
|
|
1538
|
+
return response.data;
|
|
801
1539
|
}
|
|
1540
|
+
await new Promise(resolve => setTimeout(resolve, poll_interval_seconds * 1000));
|
|
1541
|
+
}
|
|
802
1542
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1543
|
+
if (cancel_at_timeout) {
|
|
1544
|
+
try {
|
|
1545
|
+
await this.cancel_async_operation(async_id);
|
|
1546
|
+
} catch (cancelError) {
|
|
1547
|
+
console.warn(`Failed to cancel async operation ${async_id} at timeout:`, cancelError);
|
|
806
1548
|
}
|
|
807
|
-
|
|
808
|
-
// Wait before polling again
|
|
809
|
-
await new Promise(resolve => setTimeout(resolve, poll_interval_ms));
|
|
810
1549
|
}
|
|
811
1550
|
|
|
812
|
-
throw new TM1TimeoutException(
|
|
1551
|
+
throw new TM1TimeoutException(
|
|
1552
|
+
`Async operation ${async_id} timed out after ${timeout_seconds} seconds`,
|
|
1553
|
+
timeout_seconds
|
|
1554
|
+
);
|
|
813
1555
|
}
|
|
814
1556
|
|
|
815
1557
|
/**
|
|
@@ -885,4 +1627,4 @@ export class RestService {
|
|
|
885
1627
|
throw new TM1RestException(`Failed to get data directory: ${error}`);
|
|
886
1628
|
}
|
|
887
1629
|
}
|
|
888
|
-
}
|
|
1630
|
+
}
|