@unito/integration-sdk 4.0.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/handler.js +6 -0
- package/dist/src/index.cjs +48 -6
- package/dist/src/resources/provider.d.ts +7 -0
- package/dist/src/resources/provider.js +42 -6
- package/dist/test/handler.test.js +3 -1
- package/dist/test/resources/provider.test.js +111 -0
- package/package.json +2 -2
- package/src/handler.ts +6 -0
- package/src/resources/provider.ts +49 -10
- package/test/handler.test.ts +3 -1
- package/test/resources/provider.test.ts +125 -0
package/dist/src/handler.js
CHANGED
|
@@ -33,11 +33,17 @@ function assertCreateItemRequestPayload(body) {
|
|
|
33
33
|
if (typeof body !== 'object' || body === null) {
|
|
34
34
|
throw new BadRequestError('Invalid CreateItemRequestPayload');
|
|
35
35
|
}
|
|
36
|
+
if (Object.keys(body).length === 0) {
|
|
37
|
+
throw new BadRequestError('Empty CreateItemRequestPayload');
|
|
38
|
+
}
|
|
36
39
|
}
|
|
37
40
|
function assertUpdateItemRequestPayload(body) {
|
|
38
41
|
if (typeof body !== 'object' || body === null) {
|
|
39
42
|
throw new BadRequestError('Invalid UpdateItemRequestPayload');
|
|
40
43
|
}
|
|
44
|
+
if (Object.keys(body).length === 0) {
|
|
45
|
+
throw new BadRequestError('Empty UpdateItemRequestPayload');
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
function assertWebhookParseRequestPayload(body) {
|
|
43
49
|
if (typeof body !== 'object' || body === null) {
|
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) {
|
|
@@ -481,11 +482,17 @@ function assertCreateItemRequestPayload(body) {
|
|
|
481
482
|
if (typeof body !== 'object' || body === null) {
|
|
482
483
|
throw new BadRequestError('Invalid CreateItemRequestPayload');
|
|
483
484
|
}
|
|
485
|
+
if (Object.keys(body).length === 0) {
|
|
486
|
+
throw new BadRequestError('Empty CreateItemRequestPayload');
|
|
487
|
+
}
|
|
484
488
|
}
|
|
485
489
|
function assertUpdateItemRequestPayload(body) {
|
|
486
490
|
if (typeof body !== 'object' || body === null) {
|
|
487
491
|
throw new BadRequestError('Invalid UpdateItemRequestPayload');
|
|
488
492
|
}
|
|
493
|
+
if (Object.keys(body).length === 0) {
|
|
494
|
+
throw new BadRequestError('Empty UpdateItemRequestPayload');
|
|
495
|
+
}
|
|
489
496
|
}
|
|
490
497
|
function assertWebhookParseRequestPayload(body) {
|
|
491
498
|
if (typeof body !== 'object' || body === null) {
|
|
@@ -1184,6 +1191,10 @@ class Provider {
|
|
|
1184
1191
|
* The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
|
|
1185
1192
|
*/
|
|
1186
1193
|
rateLimiter = undefined;
|
|
1194
|
+
static recordingSeq = 0;
|
|
1195
|
+
static get recordingPath() {
|
|
1196
|
+
return process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
|
|
1197
|
+
}
|
|
1187
1198
|
/**
|
|
1188
1199
|
* Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
|
|
1189
1200
|
*
|
|
@@ -1307,9 +1318,7 @@ class Provider {
|
|
|
1307
1318
|
}
|
|
1308
1319
|
resolve({
|
|
1309
1320
|
status: 201,
|
|
1310
|
-
headers:
|
|
1311
|
-
.filter((entry) => entry[1] !== undefined)
|
|
1312
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
1321
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
1313
1322
|
body,
|
|
1314
1323
|
});
|
|
1315
1324
|
}
|
|
@@ -1410,9 +1419,7 @@ class Provider {
|
|
|
1410
1419
|
const body = responseBody ? JSON.parse(responseBody) : undefined;
|
|
1411
1420
|
safeResolve({
|
|
1412
1421
|
status: response.statusCode || 200,
|
|
1413
|
-
headers:
|
|
1414
|
-
.filter((entry) => entry[1] !== undefined)
|
|
1415
|
-
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
1422
|
+
headers: Provider.normalizeNodeHeaders(response.headers),
|
|
1416
1423
|
body,
|
|
1417
1424
|
});
|
|
1418
1425
|
}
|
|
@@ -1626,6 +1633,32 @@ class Provider {
|
|
|
1626
1633
|
},
|
|
1627
1634
|
},
|
|
1628
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
|
+
}
|
|
1629
1662
|
if (response.status >= 400) {
|
|
1630
1663
|
const textResult = await response.text();
|
|
1631
1664
|
throw this.handleError(response.status, textResult, options);
|
|
@@ -1671,6 +1704,15 @@ class Provider {
|
|
|
1671
1704
|
};
|
|
1672
1705
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
1673
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
|
+
}
|
|
1674
1716
|
handleError(responseStatus, message, options) {
|
|
1675
1717
|
const customError = this.customErrorHandler?.(responseStatus, message, options);
|
|
1676
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);
|
|
@@ -160,11 +160,13 @@ describe('Handler', () => {
|
|
|
160
160
|
await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } }));
|
|
161
161
|
await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError);
|
|
162
162
|
await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError);
|
|
163
|
+
await assert.rejects(async () => await executeHandler(createHandler, { body: {} }), BadRequestError);
|
|
163
164
|
// UpdateItemRequestPayload.
|
|
164
|
-
const updateHandler = routes[
|
|
165
|
+
const updateHandler = routes[1].route.stack[0].handle;
|
|
165
166
|
await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } }));
|
|
166
167
|
await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError);
|
|
167
168
|
await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError);
|
|
169
|
+
await assert.rejects(async () => await executeHandler(updateHandler, { body: {} }), BadRequestError);
|
|
168
170
|
});
|
|
169
171
|
});
|
|
170
172
|
});
|
|
@@ -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",
|
package/src/handler.ts
CHANGED
|
@@ -216,12 +216,18 @@ function assertCreateItemRequestPayload(body: unknown): asserts body is API.Crea
|
|
|
216
216
|
if (typeof body !== 'object' || body === null) {
|
|
217
217
|
throw new BadRequestError('Invalid CreateItemRequestPayload');
|
|
218
218
|
}
|
|
219
|
+
if (Object.keys(body).length === 0) {
|
|
220
|
+
throw new BadRequestError('Empty CreateItemRequestPayload');
|
|
221
|
+
}
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
function assertUpdateItemRequestPayload(body: unknown): asserts body is API.UpdateItemRequestPayload {
|
|
222
225
|
if (typeof body !== 'object' || body === null) {
|
|
223
226
|
throw new BadRequestError('Invalid UpdateItemRequestPayload');
|
|
224
227
|
}
|
|
228
|
+
if (Object.keys(body).length === 0) {
|
|
229
|
+
throw new BadRequestError('Empty UpdateItemRequestPayload');
|
|
230
|
+
}
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
function assertWebhookParseRequestPayload(body: unknown): asserts body is API.WebhookParseRequestPayload {
|
|
@@ -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
|
|
package/test/handler.test.ts
CHANGED
|
@@ -201,12 +201,14 @@ describe('Handler', () => {
|
|
|
201
201
|
await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } }));
|
|
202
202
|
await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError);
|
|
203
203
|
await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError);
|
|
204
|
+
await assert.rejects(async () => await executeHandler(createHandler, { body: {} }), BadRequestError);
|
|
204
205
|
|
|
205
206
|
// UpdateItemRequestPayload.
|
|
206
|
-
const updateHandler = routes[
|
|
207
|
+
const updateHandler = routes[1]!.route!.stack[0]!.handle;
|
|
207
208
|
await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } }));
|
|
208
209
|
await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError);
|
|
209
210
|
await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError);
|
|
211
|
+
await assert.rejects(async () => await executeHandler(updateHandler, { body: {} }), BadRequestError);
|
|
210
212
|
});
|
|
211
213
|
});
|
|
212
214
|
});
|
|
@@ -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
|
});
|