@unito/integration-sdk 4.1.0 → 4.2.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/src/index.cjs
CHANGED
|
@@ -6,6 +6,7 @@ var crypto = require('crypto');
|
|
|
6
6
|
var util = require('util');
|
|
7
7
|
var express = require('express');
|
|
8
8
|
var busboy = require('busboy');
|
|
9
|
+
var fs = require('fs');
|
|
9
10
|
var https = require('https');
|
|
10
11
|
|
|
11
12
|
function _interopNamespaceDefault(e) {
|
|
@@ -1190,6 +1191,10 @@ class Provider {
|
|
|
1190
1191
|
* The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
|
|
1191
1192
|
*/
|
|
1192
1193
|
rateLimiter = undefined;
|
|
1194
|
+
static recordingSeq = 0;
|
|
1195
|
+
static get recordingPath() {
|
|
1196
|
+
return process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1197
|
+
}
|
|
1193
1198
|
/**
|
|
1194
1199
|
* Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
|
|
1195
1200
|
*
|
|
@@ -1313,9 +1318,7 @@ class Provider {
|
|
|
1313
1318
|
}
|
|
1314
1319
|
resolve({
|
|
1315
1320
|
status: 201,
|
|
1316
|
-
headers:
|
|
1317
|
-
.filter((entry) => entry[1] !== undefined)
|
|
1318
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
1321
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
1319
1322
|
body,
|
|
1320
1323
|
});
|
|
1321
1324
|
}
|
|
@@ -1416,9 +1419,7 @@ class Provider {
|
|
|
1416
1419
|
const body = responseBody ? JSON.parse(responseBody) : undefined;
|
|
1417
1420
|
safeResolve({
|
|
1418
1421
|
status: response.statusCode || 200,
|
|
1419
|
-
headers:
|
|
1420
|
-
.filter((entry) => entry[1] !== undefined)
|
|
1421
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
1422
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
1422
1423
|
body,
|
|
1423
1424
|
});
|
|
1424
1425
|
}
|
|
@@ -1632,6 +1633,32 @@ class Provider {
|
|
|
1632
1633
|
},
|
|
1633
1634
|
},
|
|
1634
1635
|
});
|
|
1636
|
+
// Record raw response for schema-snapshot coverage analysis.
|
|
1637
|
+
// Clone the response before consuming the body so recording doesn't interfere with normal flow.
|
|
1638
|
+
// Only record JSON-like responses (skip binary streams and raw body requests).
|
|
1639
|
+
if (Provider.recordingPath &&
|
|
1640
|
+
!options.rawBody &&
|
|
1641
|
+
headers.Accept !== 'application/octet-stream' &&
|
|
1642
|
+
response.status < 400) {
|
|
1643
|
+
try {
|
|
1644
|
+
const cloned = response.clone();
|
|
1645
|
+
const recordBody = await cloned.json().catch(() => undefined);
|
|
1646
|
+
if (recordBody !== undefined) {
|
|
1647
|
+
Provider.recordingSeq++;
|
|
1648
|
+
const entry = JSON.stringify({
|
|
1649
|
+
seq: Provider.recordingSeq,
|
|
1650
|
+
url: absoluteUrl,
|
|
1651
|
+
method: options.method,
|
|
1652
|
+
status: response.status,
|
|
1653
|
+
body: recordBody,
|
|
1654
|
+
});
|
|
1655
|
+
fs.appendFileSync(Provider.recordingPath, entry + '\n');
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
catch {
|
|
1659
|
+
// Recording failure should never break the integration.
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1635
1662
|
if (response.status >= 400) {
|
|
1636
1663
|
const textResult = await response.text();
|
|
1637
1664
|
throw this.handleError(response.status, textResult, options);
|
|
@@ -1677,6 +1704,15 @@ class Provider {
|
|
|
1677
1704
|
};
|
|
1678
1705
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
1679
1706
|
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
|
|
1709
|
+
* into a flat Record<string, string> by joining array values and dropping undefined entries.
|
|
1710
|
+
*/
|
|
1711
|
+
static normalizeNodeHeaders(headers) {
|
|
1712
|
+
return Object.fromEntries(Object.entries(headers)
|
|
1713
|
+
.filter((entry) => entry[1] !== undefined)
|
|
1714
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]));
|
|
1715
|
+
}
|
|
1680
1716
|
handleError(responseStatus, message, options) {
|
|
1681
1717
|
const customError = this.customErrorHandler?.(responseStatus, message, options);
|
|
1682
1718
|
return customError ?? buildHttpError(responseStatus, message);
|
|
@@ -80,6 +80,8 @@ export declare class Provider {
|
|
|
80
80
|
* The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
|
|
81
81
|
*/
|
|
82
82
|
protected rateLimiter: RateLimiter | undefined;
|
|
83
|
+
private static recordingSeq;
|
|
84
|
+
private static get recordingPath();
|
|
83
85
|
/**
|
|
84
86
|
* Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
|
|
85
87
|
*
|
|
@@ -219,5 +221,10 @@ export declare class Provider {
|
|
|
219
221
|
delete<T = undefined>(endpoint: string, options: RequestOptions, body?: RequestBody | null): Promise<Response<T>>;
|
|
220
222
|
private generateAbsoluteUrl;
|
|
221
223
|
private fetchWrapper;
|
|
224
|
+
/**
|
|
225
|
+
* Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
|
|
226
|
+
* into a flat Record<string, string> by joining array values and dropping undefined entries.
|
|
227
|
+
*/
|
|
228
|
+
private static normalizeNodeHeaders;
|
|
222
229
|
private handleError;
|
|
223
230
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
1
2
|
import https from 'https';
|
|
2
3
|
import { buildHttpError } from '../errors.js';
|
|
3
4
|
/**
|
|
@@ -19,6 +20,10 @@ export class Provider {
|
|
|
19
20
|
* The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
|
|
20
21
|
*/
|
|
21
22
|
rateLimiter = undefined;
|
|
23
|
+
static recordingSeq = 0;
|
|
24
|
+
static get recordingPath() {
|
|
25
|
+
return process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
26
|
+
}
|
|
22
27
|
/**
|
|
23
28
|
* Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
|
|
24
29
|
*
|
|
@@ -142,9 +147,7 @@ export class Provider {
|
|
|
142
147
|
}
|
|
143
148
|
resolve({
|
|
144
149
|
status: 201,
|
|
145
|
-
headers:
|
|
146
|
-
.filter((entry) => entry[1] !== undefined)
|
|
147
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
150
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
148
151
|
body,
|
|
149
152
|
});
|
|
150
153
|
}
|
|
@@ -245,9 +248,7 @@ export class Provider {
|
|
|
245
248
|
const body = responseBody ? JSON.parse(responseBody) : undefined;
|
|
246
249
|
safeResolve({
|
|
247
250
|
status: response.statusCode || 200,
|
|
248
|
-
headers:
|
|
249
|
-
.filter((entry) => entry[1] !== undefined)
|
|
250
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
251
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
251
252
|
body,
|
|
252
253
|
});
|
|
253
254
|
}
|
|
@@ -461,6 +462,32 @@ export class Provider {
|
|
|
461
462
|
},
|
|
462
463
|
},
|
|
463
464
|
});
|
|
465
|
+
// Record raw response for schema-snapshot coverage analysis.
|
|
466
|
+
// Clone the response before consuming the body so recording doesn't interfere with normal flow.
|
|
467
|
+
// Only record JSON-like responses (skip binary streams and raw body requests).
|
|
468
|
+
if (Provider.recordingPath &&
|
|
469
|
+
!options.rawBody &&
|
|
470
|
+
headers.Accept !== 'application/octet-stream' &&
|
|
471
|
+
response.status < 400) {
|
|
472
|
+
try {
|
|
473
|
+
const cloned = response.clone();
|
|
474
|
+
const recordBody = await cloned.json().catch(() => undefined);
|
|
475
|
+
if (recordBody !== undefined) {
|
|
476
|
+
Provider.recordingSeq++;
|
|
477
|
+
const entry = JSON.stringify({
|
|
478
|
+
seq: Provider.recordingSeq,
|
|
479
|
+
url: absoluteUrl,
|
|
480
|
+
method: options.method,
|
|
481
|
+
status: response.status,
|
|
482
|
+
body: recordBody,
|
|
483
|
+
});
|
|
484
|
+
fs.appendFileSync(Provider.recordingPath, entry + '\n');
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// Recording failure should never break the integration.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
464
491
|
if (response.status >= 400) {
|
|
465
492
|
const textResult = await response.text();
|
|
466
493
|
throw this.handleError(response.status, textResult, options);
|
|
@@ -506,6 +533,15 @@ export class Provider {
|
|
|
506
533
|
};
|
|
507
534
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
508
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
|
|
538
|
+
* into a flat Record<string, string> by joining array values and dropping undefined entries.
|
|
539
|
+
*/
|
|
540
|
+
static normalizeNodeHeaders(headers) {
|
|
541
|
+
return Object.fromEntries(Object.entries(headers)
|
|
542
|
+
.filter((entry) => entry[1] !== undefined)
|
|
543
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]));
|
|
544
|
+
}
|
|
509
545
|
handleError(responseStatus, message, options) {
|
|
510
546
|
const customError = this.customErrorHandler?.(responseStatus, message, options);
|
|
511
547
|
return customError ?? buildHttpError(responseStatus, message);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { describe, it } from 'node:test';
|
|
3
|
+
import fs from 'fs';
|
|
3
4
|
import { Readable } from 'stream';
|
|
4
5
|
import nock from 'nock';
|
|
5
6
|
import https from 'https';
|
|
@@ -1225,4 +1226,114 @@ describe('Provider', () => {
|
|
|
1225
1226
|
// Verify the signal is indeed aborted
|
|
1226
1227
|
assert.ok(abortController.signal.aborted, 'Signal should be aborted');
|
|
1227
1228
|
});
|
|
1229
|
+
describe('response recording', () => {
|
|
1230
|
+
it('records JSON responses to JSONL file when env var is set', async () => {
|
|
1231
|
+
const tmpFile = `/tmp/provider-recording-test-${Date.now()}.jsonl`;
|
|
1232
|
+
process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
|
|
1233
|
+
try {
|
|
1234
|
+
const recordingProvider = new Provider({
|
|
1235
|
+
prepareRequest: () => ({
|
|
1236
|
+
url: 'https://www.myApi.com',
|
|
1237
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1238
|
+
}),
|
|
1239
|
+
});
|
|
1240
|
+
nock('https://www.myApi.com').get('/items').reply(200, { id: '123', name: 'Test', priority: 'high' });
|
|
1241
|
+
await recordingProvider.get('/items', {
|
|
1242
|
+
credentials: { unitoCredentialId: '123' },
|
|
1243
|
+
logger,
|
|
1244
|
+
});
|
|
1245
|
+
const content = fs.readFileSync(tmpFile, 'utf-8');
|
|
1246
|
+
const lines = content.trim().split('\n');
|
|
1247
|
+
assert.equal(lines.length, 1);
|
|
1248
|
+
const entry = JSON.parse(lines[0]);
|
|
1249
|
+
assert.ok(typeof entry.seq === 'number' && entry.seq > 0, `seq should be a positive number, got ${entry.seq}`);
|
|
1250
|
+
assert.equal(entry.url, 'https://www.myApi.com/items');
|
|
1251
|
+
assert.equal(entry.method, 'GET');
|
|
1252
|
+
assert.equal(entry.status, 200);
|
|
1253
|
+
assert.deepEqual(entry.body, { id: '123', name: 'Test', priority: 'high' });
|
|
1254
|
+
}
|
|
1255
|
+
finally {
|
|
1256
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1257
|
+
try {
|
|
1258
|
+
fs.unlinkSync(tmpFile);
|
|
1259
|
+
}
|
|
1260
|
+
catch {
|
|
1261
|
+
/* ignore */
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
it('does not record when env var is not set', async () => {
|
|
1266
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1267
|
+
const noRecordProvider = new Provider({
|
|
1268
|
+
prepareRequest: () => ({
|
|
1269
|
+
url: 'https://www.myApi.com',
|
|
1270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1271
|
+
}),
|
|
1272
|
+
});
|
|
1273
|
+
nock('https://www.myApi.com').get('/no-record').reply(200, { id: '123' });
|
|
1274
|
+
await noRecordProvider.get('/no-record', {
|
|
1275
|
+
credentials: { unitoCredentialId: '123' },
|
|
1276
|
+
logger,
|
|
1277
|
+
});
|
|
1278
|
+
// No assertion needed — just ensure no crash.
|
|
1279
|
+
});
|
|
1280
|
+
it('does not record stream responses', async () => {
|
|
1281
|
+
const tmpFile = `/tmp/provider-recording-stream-test-${Date.now()}.jsonl`;
|
|
1282
|
+
process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
|
|
1283
|
+
try {
|
|
1284
|
+
const recordingProvider = new Provider({
|
|
1285
|
+
prepareRequest: () => ({
|
|
1286
|
+
url: 'https://www.myApi.com',
|
|
1287
|
+
headers: {},
|
|
1288
|
+
}),
|
|
1289
|
+
});
|
|
1290
|
+
nock('https://www.myApi.com')
|
|
1291
|
+
.get('/download')
|
|
1292
|
+
.reply(200, 'binary-data', { 'Content-Type': 'application/octet-stream' });
|
|
1293
|
+
await recordingProvider.streamingGet('/download', {
|
|
1294
|
+
credentials: { unitoCredentialId: '123' },
|
|
1295
|
+
logger,
|
|
1296
|
+
});
|
|
1297
|
+
assert.equal(fs.existsSync(tmpFile), false);
|
|
1298
|
+
}
|
|
1299
|
+
finally {
|
|
1300
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1301
|
+
try {
|
|
1302
|
+
fs.unlinkSync(tmpFile);
|
|
1303
|
+
}
|
|
1304
|
+
catch {
|
|
1305
|
+
/* ignore */
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
it('increments seq across multiple requests', async () => {
|
|
1310
|
+
const tmpFile = `/tmp/provider-recording-seq-test-${Date.now()}.jsonl`;
|
|
1311
|
+
process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
|
|
1312
|
+
try {
|
|
1313
|
+
const recordingProvider = new Provider({
|
|
1314
|
+
prepareRequest: () => ({
|
|
1315
|
+
url: 'https://www.myApi.com',
|
|
1316
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1317
|
+
}),
|
|
1318
|
+
});
|
|
1319
|
+
nock('https://www.myApi.com').get('/a').reply(200, { id: '1' }).get('/b').reply(200, { id: '2' });
|
|
1320
|
+
await recordingProvider.get('/a', { credentials: { unitoCredentialId: '123' }, logger });
|
|
1321
|
+
await recordingProvider.get('/b', { credentials: { unitoCredentialId: '123' }, logger });
|
|
1322
|
+
const lines = fs.readFileSync(tmpFile, 'utf-8').trim().split('\n');
|
|
1323
|
+
assert.equal(lines.length, 2);
|
|
1324
|
+
const seq1 = JSON.parse(lines[0]).seq;
|
|
1325
|
+
const seq2 = JSON.parse(lines[1]).seq;
|
|
1326
|
+
assert.equal(seq2, seq1 + 1, `second seq (${seq2}) should be first seq (${seq1}) + 1`);
|
|
1327
|
+
}
|
|
1328
|
+
finally {
|
|
1329
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1330
|
+
try {
|
|
1331
|
+
fs.unlinkSync(tmpFile);
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
/* ignore */
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
});
|
|
1228
1339
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unito/integration-sdk",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Integration SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "dist/src/index.d.ts",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@types/express": "5.x",
|
|
56
|
-
"@unito/integration-api": "
|
|
56
|
+
"@unito/integration-api": "6.x",
|
|
57
57
|
"busboy": "^1.6.0",
|
|
58
58
|
"cachette": "4.x",
|
|
59
59
|
"express": "^5.1",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
1
2
|
import https from 'https';
|
|
2
3
|
import FormData from 'form-data';
|
|
3
4
|
import * as stream from 'stream';
|
|
@@ -85,6 +86,12 @@ export class Provider {
|
|
|
85
86
|
* The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
|
|
86
87
|
*/
|
|
87
88
|
protected rateLimiter: RateLimiter | undefined = undefined;
|
|
89
|
+
|
|
90
|
+
private static recordingSeq = 0;
|
|
91
|
+
private static get recordingPath(): string | undefined {
|
|
92
|
+
return process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
/**
|
|
89
96
|
* Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
|
|
90
97
|
*
|
|
@@ -232,11 +239,7 @@ export class Provider {
|
|
|
232
239
|
}
|
|
233
240
|
resolve({
|
|
234
241
|
status: 201,
|
|
235
|
-
headers:
|
|
236
|
-
Object.entries(response.headers)
|
|
237
|
-
.filter((entry): entry is [string, string | string[]] => entry[1] !== undefined)
|
|
238
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]),
|
|
239
|
-
),
|
|
242
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
240
243
|
body,
|
|
241
244
|
});
|
|
242
245
|
} catch (error) {
|
|
@@ -353,11 +356,7 @@ export class Provider {
|
|
|
353
356
|
const body = responseBody ? JSON.parse(responseBody) : undefined;
|
|
354
357
|
safeResolve({
|
|
355
358
|
status: response.statusCode || 200,
|
|
356
|
-
headers:
|
|
357
|
-
Object.entries(response.headers)
|
|
358
|
-
.filter((entry): entry is [string, string | string[]] => entry[1] !== undefined)
|
|
359
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]),
|
|
360
|
-
),
|
|
359
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
361
360
|
body,
|
|
362
361
|
});
|
|
363
362
|
} catch (error) {
|
|
@@ -613,6 +612,34 @@ export class Provider {
|
|
|
613
612
|
},
|
|
614
613
|
);
|
|
615
614
|
|
|
615
|
+
// Record raw response for schema-snapshot coverage analysis.
|
|
616
|
+
// Clone the response before consuming the body so recording doesn't interfere with normal flow.
|
|
617
|
+
// Only record JSON-like responses (skip binary streams and raw body requests).
|
|
618
|
+
if (
|
|
619
|
+
Provider.recordingPath &&
|
|
620
|
+
!options.rawBody &&
|
|
621
|
+
headers.Accept !== 'application/octet-stream' &&
|
|
622
|
+
response.status < 400
|
|
623
|
+
) {
|
|
624
|
+
try {
|
|
625
|
+
const cloned = response.clone();
|
|
626
|
+
const recordBody = await cloned.json().catch(() => undefined);
|
|
627
|
+
if (recordBody !== undefined) {
|
|
628
|
+
Provider.recordingSeq++;
|
|
629
|
+
const entry = JSON.stringify({
|
|
630
|
+
seq: Provider.recordingSeq,
|
|
631
|
+
url: absoluteUrl,
|
|
632
|
+
method: options.method,
|
|
633
|
+
status: response.status,
|
|
634
|
+
body: recordBody,
|
|
635
|
+
});
|
|
636
|
+
fs.appendFileSync(Provider.recordingPath, entry + '\n');
|
|
637
|
+
}
|
|
638
|
+
} catch {
|
|
639
|
+
// Recording failure should never break the integration.
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
616
643
|
if (response.status >= 400) {
|
|
617
644
|
const textResult = await response.text();
|
|
618
645
|
throw this.handleError(response.status, textResult, options);
|
|
@@ -663,6 +690,18 @@ export class Provider {
|
|
|
663
690
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
664
691
|
}
|
|
665
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
|
|
695
|
+
* into a flat Record<string, string> by joining array values and dropping undefined entries.
|
|
696
|
+
*/
|
|
697
|
+
private static normalizeNodeHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string> {
|
|
698
|
+
return Object.fromEntries(
|
|
699
|
+
Object.entries(headers)
|
|
700
|
+
.filter((entry): entry is [string, string | string[]] => entry[1] !== undefined)
|
|
701
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]),
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
666
705
|
private handleError(responseStatus: number, message: string, options: RequestOptions): HttpErrors.HttpError {
|
|
667
706
|
const customError = this.customErrorHandler?.(responseStatus, message, options);
|
|
668
707
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { describe, it } from 'node:test';
|
|
3
|
+
import fs from 'fs';
|
|
3
4
|
import { Readable } from 'stream';
|
|
4
5
|
import nock from 'nock';
|
|
5
6
|
import https from 'https';
|
|
@@ -1461,4 +1462,128 @@ describe('Provider', () => {
|
|
|
1461
1462
|
// Verify the signal is indeed aborted
|
|
1462
1463
|
assert.ok(abortController.signal.aborted, 'Signal should be aborted');
|
|
1463
1464
|
});
|
|
1465
|
+
|
|
1466
|
+
describe('response recording', () => {
|
|
1467
|
+
it('records JSON responses to JSONL file when env var is set', async () => {
|
|
1468
|
+
const tmpFile = `/tmp/provider-recording-test-${Date.now()}.jsonl`;
|
|
1469
|
+
process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
|
|
1470
|
+
|
|
1471
|
+
try {
|
|
1472
|
+
const recordingProvider = new Provider({
|
|
1473
|
+
prepareRequest: () => ({
|
|
1474
|
+
url: 'https://www.myApi.com',
|
|
1475
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1476
|
+
}),
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
nock('https://www.myApi.com').get('/items').reply(200, { id: '123', name: 'Test', priority: 'high' });
|
|
1480
|
+
|
|
1481
|
+
await recordingProvider.get('/items', {
|
|
1482
|
+
credentials: { unitoCredentialId: '123' },
|
|
1483
|
+
logger,
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
const content = fs.readFileSync(tmpFile, 'utf-8');
|
|
1487
|
+
const lines = content.trim().split('\n');
|
|
1488
|
+
assert.equal(lines.length, 1);
|
|
1489
|
+
|
|
1490
|
+
const entry = JSON.parse(lines[0]!);
|
|
1491
|
+
assert.ok(typeof entry.seq === 'number' && entry.seq > 0, `seq should be a positive number, got ${entry.seq}`);
|
|
1492
|
+
assert.equal(entry.url, 'https://www.myApi.com/items');
|
|
1493
|
+
assert.equal(entry.method, 'GET');
|
|
1494
|
+
assert.equal(entry.status, 200);
|
|
1495
|
+
assert.deepEqual(entry.body, { id: '123', name: 'Test', priority: 'high' });
|
|
1496
|
+
} finally {
|
|
1497
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1498
|
+
try {
|
|
1499
|
+
fs.unlinkSync(tmpFile);
|
|
1500
|
+
} catch {
|
|
1501
|
+
/* ignore */
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
it('does not record when env var is not set', async () => {
|
|
1507
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1508
|
+
|
|
1509
|
+
const noRecordProvider = new Provider({
|
|
1510
|
+
prepareRequest: () => ({
|
|
1511
|
+
url: 'https://www.myApi.com',
|
|
1512
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1513
|
+
}),
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
nock('https://www.myApi.com').get('/no-record').reply(200, { id: '123' });
|
|
1517
|
+
|
|
1518
|
+
await noRecordProvider.get('/no-record', {
|
|
1519
|
+
credentials: { unitoCredentialId: '123' },
|
|
1520
|
+
logger,
|
|
1521
|
+
});
|
|
1522
|
+
// No assertion needed — just ensure no crash.
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
it('does not record stream responses', async () => {
|
|
1526
|
+
const tmpFile = `/tmp/provider-recording-stream-test-${Date.now()}.jsonl`;
|
|
1527
|
+
process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
|
|
1528
|
+
|
|
1529
|
+
try {
|
|
1530
|
+
const recordingProvider = new Provider({
|
|
1531
|
+
prepareRequest: () => ({
|
|
1532
|
+
url: 'https://www.myApi.com',
|
|
1533
|
+
headers: {},
|
|
1534
|
+
}),
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
nock('https://www.myApi.com')
|
|
1538
|
+
.get('/download')
|
|
1539
|
+
.reply(200, 'binary-data', { 'Content-Type': 'application/octet-stream' });
|
|
1540
|
+
|
|
1541
|
+
await recordingProvider.streamingGet('/download', {
|
|
1542
|
+
credentials: { unitoCredentialId: '123' },
|
|
1543
|
+
logger,
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
assert.equal(fs.existsSync(tmpFile), false);
|
|
1547
|
+
} finally {
|
|
1548
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1549
|
+
try {
|
|
1550
|
+
fs.unlinkSync(tmpFile);
|
|
1551
|
+
} catch {
|
|
1552
|
+
/* ignore */
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it('increments seq across multiple requests', async () => {
|
|
1558
|
+
const tmpFile = `/tmp/provider-recording-seq-test-${Date.now()}.jsonl`;
|
|
1559
|
+
process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
|
|
1560
|
+
|
|
1561
|
+
try {
|
|
1562
|
+
const recordingProvider = new Provider({
|
|
1563
|
+
prepareRequest: () => ({
|
|
1564
|
+
url: 'https://www.myApi.com',
|
|
1565
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1566
|
+
}),
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
nock('https://www.myApi.com').get('/a').reply(200, { id: '1' }).get('/b').reply(200, { id: '2' });
|
|
1570
|
+
|
|
1571
|
+
await recordingProvider.get('/a', { credentials: { unitoCredentialId: '123' }, logger });
|
|
1572
|
+
await recordingProvider.get('/b', { credentials: { unitoCredentialId: '123' }, logger });
|
|
1573
|
+
|
|
1574
|
+
const lines = fs.readFileSync(tmpFile, 'utf-8').trim().split('\n');
|
|
1575
|
+
assert.equal(lines.length, 2);
|
|
1576
|
+
const seq1 = JSON.parse(lines[0]!).seq;
|
|
1577
|
+
const seq2 = JSON.parse(lines[1]!).seq;
|
|
1578
|
+
assert.equal(seq2, seq1 + 1, `second seq (${seq2}) should be first seq (${seq1}) + 1`);
|
|
1579
|
+
} finally {
|
|
1580
|
+
delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1581
|
+
try {
|
|
1582
|
+
fs.unlinkSync(tmpFile);
|
|
1583
|
+
} catch {
|
|
1584
|
+
/* ignore */
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1464
1589
|
});
|