@icedq/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +43 -43
- package/src/commands/export.js +102 -100
- package/src/core/client.js +183 -182
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@icedq/cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "CLI for iceDQ rule and workflow promotion across environments",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/bin/icedq.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"icedq": "./src/bin/icedq.js"
|
|
9
|
-
},
|
|
10
|
-
"scripts": {
|
|
11
|
-
"test": "node --test tests/unit
|
|
12
|
-
"start": "node src/bin/icedq.js"
|
|
13
|
-
},
|
|
14
|
-
"engines": {
|
|
15
|
-
"node": ">=18.0.0"
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"icedq",
|
|
19
|
-
"data-quality",
|
|
20
|
-
"ci-cd",
|
|
21
|
-
"promotion",
|
|
22
|
-
"github-action"
|
|
23
|
-
],
|
|
24
|
-
"author": "iceDQ Integration Team",
|
|
25
|
-
"license": "MIT",
|
|
26
|
-
"homepage": "https://github.com/icedq-tools/cli#readme",
|
|
27
|
-
"repository": {
|
|
28
|
-
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/icedq-tools/cli.git"
|
|
30
|
-
},
|
|
31
|
-
"bugs": {
|
|
32
|
-
"url": "https://github.com/icedq-tools/cli/issues"
|
|
33
|
-
},
|
|
34
|
-
"dependencies": {
|
|
35
|
-
"commander": "^12.0.0",
|
|
36
|
-
"form-data": "^4.0.0"
|
|
37
|
-
},
|
|
38
|
-
"files": [
|
|
39
|
-
"src/",
|
|
40
|
-
"README.md",
|
|
41
|
-
"LICENSE"
|
|
42
|
-
]
|
|
43
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@icedq/cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "CLI for iceDQ rule and workflow promotion across environments",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/bin/icedq.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"icedq": "./src/bin/icedq.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test tests/unit/*.test.js",
|
|
12
|
+
"start": "node src/bin/icedq.js"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"icedq",
|
|
19
|
+
"data-quality",
|
|
20
|
+
"ci-cd",
|
|
21
|
+
"promotion",
|
|
22
|
+
"github-action"
|
|
23
|
+
],
|
|
24
|
+
"author": "iceDQ Integration Team",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://github.com/icedq-tools/cli#readme",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/icedq-tools/cli.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/icedq-tools/cli/issues"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"commander": "^12.0.0",
|
|
36
|
+
"form-data": "^4.0.0"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src/",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
]
|
|
43
|
+
}
|
package/src/commands/export.js
CHANGED
|
@@ -1,100 +1,102 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { loadConfig } from '../core/config.js';
|
|
4
|
-
import { KeycloakClientCredentialsAuth } from '../core/auth.js';
|
|
5
|
-
import { IcedqApiClient } from '../core/client.js';
|
|
6
|
-
import { pollTask } from '../core/poller.js';
|
|
7
|
-
import { Reporter } from '../core/reporter.js';
|
|
8
|
-
import { isSuccess, STATUS } from '../lib/status-enum.js';
|
|
9
|
-
import { TaskFailedError, CliError } from '../core/errors.js';
|
|
10
|
-
import { log, setLevel } from '../core/logger.js';
|
|
11
|
-
|
|
12
|
-
const VALID_RESOURCES = new Set(['rule', 'workflow', 'folder']);
|
|
13
|
-
|
|
14
|
-
function endpointForResource(resource) {
|
|
15
|
-
// Rules export uses /exports/rules; workflows + folders use /exports/workflows
|
|
16
|
-
return resource === 'rule' ? 'rules' : 'workflows';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function runExport(rawOpts) {
|
|
20
|
-
if (rawOpts.verbose) setLevel('debug');
|
|
21
|
-
if (rawOpts.quiet) setLevel('error');
|
|
22
|
-
|
|
23
|
-
const resource = rawOpts.resource;
|
|
24
|
-
if (!VALID_RESOURCES.has(resource)) {
|
|
25
|
-
throw new CliError(`--resource must be one of: rule, workflow, folder`);
|
|
26
|
-
}
|
|
27
|
-
if (!rawOpts.id) throw new CliError('--id is required');
|
|
28
|
-
if (!rawOpts.outputFile) throw new CliError('--output-file is required');
|
|
29
|
-
|
|
30
|
-
const cfg = loadConfig(rawOpts);
|
|
31
|
-
const auth = new KeycloakClientCredentialsAuth({
|
|
32
|
-
keycloakUrl: cfg.keycloakUrl,
|
|
33
|
-
clientId: cfg.clientId,
|
|
34
|
-
clientSecret: cfg.clientSecret,
|
|
35
|
-
verifySsl: cfg.verifySsl
|
|
36
|
-
});
|
|
37
|
-
const client = new IcedqApiClient({
|
|
38
|
-
baseUrl: cfg.icedqUrl,
|
|
39
|
-
orgId: cfg.orgId,
|
|
40
|
-
accountId: cfg.accountId,
|
|
41
|
-
workspaceId: cfg.workspaceId,
|
|
42
|
-
auth,
|
|
43
|
-
verifySsl: cfg.verifySsl
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const kindEndpoint = endpointForResource(resource);
|
|
47
|
-
const body =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from '../core/config.js';
|
|
4
|
+
import { KeycloakClientCredentialsAuth } from '../core/auth.js';
|
|
5
|
+
import { IcedqApiClient } from '../core/client.js';
|
|
6
|
+
import { pollTask } from '../core/poller.js';
|
|
7
|
+
import { Reporter } from '../core/reporter.js';
|
|
8
|
+
import { isSuccess, STATUS } from '../lib/status-enum.js';
|
|
9
|
+
import { TaskFailedError, CliError } from '../core/errors.js';
|
|
10
|
+
import { log, setLevel } from '../core/logger.js';
|
|
11
|
+
|
|
12
|
+
const VALID_RESOURCES = new Set(['rule', 'workflow', 'folder']);
|
|
13
|
+
|
|
14
|
+
function endpointForResource(resource) {
|
|
15
|
+
// Rules export uses /exports/rules; workflows + folders use /exports/workflows
|
|
16
|
+
return resource === 'rule' ? 'rules' : 'workflows';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runExport(rawOpts) {
|
|
20
|
+
if (rawOpts.verbose) setLevel('debug');
|
|
21
|
+
if (rawOpts.quiet) setLevel('error');
|
|
22
|
+
|
|
23
|
+
const resource = rawOpts.resource;
|
|
24
|
+
if (!VALID_RESOURCES.has(resource)) {
|
|
25
|
+
throw new CliError(`--resource must be one of: rule, workflow, folder`);
|
|
26
|
+
}
|
|
27
|
+
if (!rawOpts.id) throw new CliError('--id is required');
|
|
28
|
+
if (!rawOpts.outputFile) throw new CliError('--output-file is required');
|
|
29
|
+
|
|
30
|
+
const cfg = loadConfig(rawOpts);
|
|
31
|
+
const auth = new KeycloakClientCredentialsAuth({
|
|
32
|
+
keycloakUrl: cfg.keycloakUrl,
|
|
33
|
+
clientId: cfg.clientId,
|
|
34
|
+
clientSecret: cfg.clientSecret,
|
|
35
|
+
verifySsl: cfg.verifySsl
|
|
36
|
+
});
|
|
37
|
+
const client = new IcedqApiClient({
|
|
38
|
+
baseUrl: cfg.icedqUrl,
|
|
39
|
+
orgId: cfg.orgId,
|
|
40
|
+
accountId: cfg.accountId,
|
|
41
|
+
workspaceId: cfg.workspaceId,
|
|
42
|
+
auth,
|
|
43
|
+
verifySsl: cfg.verifySsl
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const kindEndpoint = endpointForResource(resource);
|
|
47
|
+
const body = {
|
|
48
|
+
objects: [
|
|
49
|
+
{
|
|
50
|
+
id: rawOpts.id,
|
|
51
|
+
resource,
|
|
52
|
+
...(rawOpts.includeChild ? { includeChild: 'true' } : {})
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
log.info(`Submitting export`, { resource, id: rawOpts.id, endpoint: kindEndpoint });
|
|
58
|
+
const submitResp = await client.post(`/api/v1/exports/${kindEndpoint}`, body);
|
|
59
|
+
const taskId = submitResp.taskInstanceId;
|
|
60
|
+
if (!taskId) {
|
|
61
|
+
throw new CliError(`Export submit did not return a taskInstanceId: ${JSON.stringify(submitResp)}`);
|
|
62
|
+
}
|
|
63
|
+
process.stderr.write(`task-id: ${taskId}\n`);
|
|
64
|
+
|
|
65
|
+
log.info('Polling export task', { taskId, timeoutSec: cfg.timeoutSec });
|
|
66
|
+
const start = Date.now();
|
|
67
|
+
const { status, attempts, elapsedMs } = await pollTask(client, 'exports', taskId, {
|
|
68
|
+
timeoutSec: cfg.timeoutSec,
|
|
69
|
+
onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let outputFile = rawOpts.outputFile;
|
|
73
|
+
let logTail;
|
|
74
|
+
|
|
75
|
+
if (isSuccess(status)) {
|
|
76
|
+
const buffer = await client.getBinary(`/api/v1/exports/${taskId}/download`);
|
|
77
|
+
outputFile = path.resolve(outputFile);
|
|
78
|
+
await writeFile(outputFile, buffer);
|
|
79
|
+
log.info('Export bundle written', { outputFile, bytes: buffer.length });
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
logTail = await client.get(`/api/v1/exports/${taskId}/log`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log.warn('Could not retrieve task log', { error: err.message });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = {
|
|
89
|
+
command: 'export',
|
|
90
|
+
taskId,
|
|
91
|
+
status,
|
|
92
|
+
attempts,
|
|
93
|
+
durationMs: elapsedMs,
|
|
94
|
+
outputFile: isSuccess(status) ? outputFile : undefined,
|
|
95
|
+
elapsedSinceSubmitMs: Date.now() - start
|
|
96
|
+
};
|
|
97
|
+
new Reporter(rawOpts.output || 'text').emit(result);
|
|
98
|
+
|
|
99
|
+
if (!isSuccess(status)) {
|
|
100
|
+
throw new TaskFailedError(taskId, 'export', status, logTail);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/core/client.js
CHANGED
|
@@ -1,182 +1,183 @@
|
|
|
1
|
-
import https from 'node:https';
|
|
2
|
-
import http from 'node:http';
|
|
3
|
-
import { URL } from 'node:url';
|
|
4
|
-
import FormData from 'form-data';
|
|
5
|
-
import { ApiError } from './errors.js';
|
|
6
|
-
import { log } from './logger.js';
|
|
7
|
-
|
|
8
|
-
const STATUS_RETRY_5XX = 3;
|
|
9
|
-
const STATUS_RETRY_DELAY_MS = 5000;
|
|
10
|
-
|
|
11
|
-
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
12
|
-
|
|
13
|
-
export class IcedqApiClient {
|
|
14
|
-
constructor({ baseUrl, orgId, accountId, workspaceId, auth, verifySsl = true }) {
|
|
15
|
-
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
16
|
-
this.orgId = orgId;
|
|
17
|
-
this.accountId = accountId;
|
|
18
|
-
this.workspaceId = workspaceId;
|
|
19
|
-
this.auth = auth;
|
|
20
|
-
this.verifySsl = verifySsl;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
_buildHeaders(extra = {}, { includeWorkspace = true } = {}) {
|
|
24
|
-
const headers = {
|
|
25
|
-
Accept: 'application/json',
|
|
26
|
-
'Org-Id': this.orgId,
|
|
27
|
-
'Account-Id': this.accountId,
|
|
28
|
-
...extra
|
|
29
|
-
};
|
|
30
|
-
if (includeWorkspace && this.workspaceId) {
|
|
31
|
-
headers['Workspace-Id'] = this.workspaceId;
|
|
32
|
-
}
|
|
33
|
-
return headers;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async get(path, opts = {}) {
|
|
37
|
-
return this._request('GET', path, opts);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async post(path, body, opts = {}) {
|
|
41
|
-
return this._request('POST', path, { ...opts, jsonBody: body });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async postMultipart(path, parts, opts = {}) {
|
|
45
|
-
const form = new FormData();
|
|
46
|
-
for (const [name, value] of Object.entries(parts)) {
|
|
47
|
-
if (value && typeof value === 'object' && value.buffer && value.filename) {
|
|
48
|
-
form.append(name, value.buffer, {
|
|
49
|
-
filename: value.filename,
|
|
50
|
-
contentType: value.contentType || 'application/octet-stream'
|
|
51
|
-
});
|
|
52
|
-
} else if (value && typeof value === 'object' && value.json !== undefined) {
|
|
53
|
-
form.append(name, JSON.stringify(value.json), { contentType: 'application/json' });
|
|
54
|
-
} else {
|
|
55
|
-
form.append(name, value);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return this._request('POST', path, { ...opts, multipart: form });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async getBinary(path, opts = {}) {
|
|
62
|
-
return this._request('GET', path, { ...opts, expectBinary: true });
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async _request(method, path, opts = {}) {
|
|
66
|
-
const includeWorkspace = opts.includeWorkspace !== false;
|
|
67
|
-
let attempt = 0;
|
|
68
|
-
let last401 = false;
|
|
69
|
-
|
|
70
|
-
while (true) {
|
|
71
|
-
attempt++;
|
|
72
|
-
const token = await this.auth.getToken();
|
|
73
|
-
const headers = this._buildHeaders(
|
|
74
|
-
{
|
|
75
|
-
Authorization: `Bearer ${token}`,
|
|
76
|
-
...(opts.headers || {})
|
|
77
|
-
},
|
|
78
|
-
{ includeWorkspace }
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
let bodyBuffer;
|
|
82
|
-
if (opts.multipart) {
|
|
83
|
-
Object.assign(headers, opts.multipart.getHeaders());
|
|
84
|
-
bodyBuffer = opts.multipart.getBuffer();
|
|
85
|
-
} else if (opts.jsonBody !== undefined) {
|
|
86
|
-
headers['Content-Type'] = 'application/json';
|
|
87
|
-
bodyBuffer = Buffer.from(JSON.stringify(opts.jsonBody));
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
res.on('
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
req.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
.
|
|
173
|
-
.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
import FormData from 'form-data';
|
|
5
|
+
import { ApiError } from './errors.js';
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
|
|
8
|
+
const STATUS_RETRY_5XX = 3;
|
|
9
|
+
const STATUS_RETRY_DELAY_MS = 5000;
|
|
10
|
+
|
|
11
|
+
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
12
|
+
|
|
13
|
+
export class IcedqApiClient {
|
|
14
|
+
constructor({ baseUrl, orgId, accountId, workspaceId, auth, verifySsl = true }) {
|
|
15
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
16
|
+
this.orgId = orgId;
|
|
17
|
+
this.accountId = accountId;
|
|
18
|
+
this.workspaceId = workspaceId;
|
|
19
|
+
this.auth = auth;
|
|
20
|
+
this.verifySsl = verifySsl;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_buildHeaders(extra = {}, { includeWorkspace = true } = {}) {
|
|
24
|
+
const headers = {
|
|
25
|
+
Accept: 'application/json',
|
|
26
|
+
'Org-Id': this.orgId,
|
|
27
|
+
'Account-Id': this.accountId,
|
|
28
|
+
...extra
|
|
29
|
+
};
|
|
30
|
+
if (includeWorkspace && this.workspaceId) {
|
|
31
|
+
headers['Workspace-Id'] = this.workspaceId;
|
|
32
|
+
}
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async get(path, opts = {}) {
|
|
37
|
+
return this._request('GET', path, opts);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async post(path, body, opts = {}) {
|
|
41
|
+
return this._request('POST', path, { ...opts, jsonBody: body });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async postMultipart(path, parts, opts = {}) {
|
|
45
|
+
const form = new FormData();
|
|
46
|
+
for (const [name, value] of Object.entries(parts)) {
|
|
47
|
+
if (value && typeof value === 'object' && value.buffer && value.filename) {
|
|
48
|
+
form.append(name, value.buffer, {
|
|
49
|
+
filename: value.filename,
|
|
50
|
+
contentType: value.contentType || 'application/octet-stream'
|
|
51
|
+
});
|
|
52
|
+
} else if (value && typeof value === 'object' && value.json !== undefined) {
|
|
53
|
+
form.append(name, JSON.stringify(value.json), { contentType: 'application/json' });
|
|
54
|
+
} else {
|
|
55
|
+
form.append(name, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return this._request('POST', path, { ...opts, multipart: form });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async getBinary(path, opts = {}) {
|
|
62
|
+
return this._request('GET', path, { ...opts, expectBinary: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async _request(method, path, opts = {}) {
|
|
66
|
+
const includeWorkspace = opts.includeWorkspace !== false;
|
|
67
|
+
let attempt = 0;
|
|
68
|
+
let last401 = false;
|
|
69
|
+
|
|
70
|
+
while (true) {
|
|
71
|
+
attempt++;
|
|
72
|
+
const token = await this.auth.getToken();
|
|
73
|
+
const headers = this._buildHeaders(
|
|
74
|
+
{
|
|
75
|
+
Authorization: `Bearer ${token}`,
|
|
76
|
+
...(opts.headers || {})
|
|
77
|
+
},
|
|
78
|
+
{ includeWorkspace }
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
let bodyBuffer;
|
|
82
|
+
if (opts.multipart) {
|
|
83
|
+
Object.assign(headers, opts.multipart.getHeaders());
|
|
84
|
+
bodyBuffer = opts.multipart.getBuffer();
|
|
85
|
+
} else if (opts.jsonBody !== undefined) {
|
|
86
|
+
headers['Content-Type'] = 'application/json';
|
|
87
|
+
bodyBuffer = Buffer.from(JSON.stringify(opts.jsonBody));
|
|
88
|
+
headers['Content-Length'] = bodyBuffer.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const url = path.startsWith('http')
|
|
92
|
+
? path
|
|
93
|
+
: `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
94
|
+
|
|
95
|
+
log.debug('iceDQ request', { method, url, attempt });
|
|
96
|
+
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await this._doRequest(method, url, headers, bodyBuffer, !!opts.expectBinary);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new ApiError(`Network error calling ${url}: ${err.message}`, { endpoint: path });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (response.status === 401 && !last401) {
|
|
105
|
+
log.warn('iceDQ 401 — refreshing token and retrying once');
|
|
106
|
+
last401 = true;
|
|
107
|
+
await this.auth.forceRefresh();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (response.status >= 500 && opts.retryOn5xx && attempt <= STATUS_RETRY_5XX) {
|
|
112
|
+
log.warn(`iceDQ ${response.status} — retrying after ${STATUS_RETRY_DELAY_MS}ms`, { attempt });
|
|
113
|
+
await sleep(STATUS_RETRY_DELAY_MS);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (response.status < 200 || response.status >= 300) {
|
|
118
|
+
const parsed = parseErrorBody(response.body);
|
|
119
|
+
throw new ApiError(`HTTP ${response.status} from ${path}: ${parsed.summary}`, {
|
|
120
|
+
status: response.status,
|
|
121
|
+
code: parsed.code,
|
|
122
|
+
messages: parsed.messages,
|
|
123
|
+
body: response.body,
|
|
124
|
+
endpoint: path
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (opts.expectBinary) return response.buffer;
|
|
129
|
+
if (!response.body) return {};
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(response.body);
|
|
132
|
+
} catch {
|
|
133
|
+
return response.body;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_doRequest(method, urlString, headers, bodyBuffer, expectBinary) {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const url = new URL(urlString);
|
|
141
|
+
const isHttps = url.protocol === 'https:';
|
|
142
|
+
const lib = isHttps ? https : http;
|
|
143
|
+
const reqOpts = { method, headers };
|
|
144
|
+
if (isHttps && !this.verifySsl) reqOpts.rejectUnauthorized = false;
|
|
145
|
+
|
|
146
|
+
const req = lib.request(url, reqOpts, (res) => {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
res.on('data', (c) => chunks.push(c));
|
|
149
|
+
res.on('end', () => {
|
|
150
|
+
if (expectBinary) {
|
|
151
|
+
resolve({ status: res.statusCode, buffer: Buffer.concat(chunks) });
|
|
152
|
+
} else {
|
|
153
|
+
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
req.on('error', reject);
|
|
158
|
+
if (bodyBuffer) req.write(bodyBuffer);
|
|
159
|
+
req.end();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseErrorBody(body) {
|
|
165
|
+
if (!body) return { summary: '(empty body)', code: undefined, messages: [] };
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(body);
|
|
168
|
+
if (parsed && typeof parsed === 'object') {
|
|
169
|
+
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
|
|
170
|
+
const summary =
|
|
171
|
+
messages
|
|
172
|
+
.map((m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`)
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.join('; ') || parsed.message || JSON.stringify(parsed);
|
|
175
|
+
return { summary, code: parsed.code, messages };
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// not JSON, fall through
|
|
179
|
+
}
|
|
180
|
+
return { summary: body.slice(0, 200), code: undefined, messages: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const _internal = { parseErrorBody };
|