@jacebenson/jsn 4.3.13-dev.80.1 → 4.3.13-dev.81.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 +1 -1
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +6 -0
package/package.json
CHANGED
package/src/sdk-batch.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Optional, read-only ServiceNow Batch REST transport. No ordinary SDK call is
|
|
2
|
+
// routed here implicitly; writes are deliberately outside this initial contract.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { AppError, errAPI, errUsage } from './errors.js';
|
|
5
|
+
|
|
6
|
+
const UNSUPPORTED = new Set([404, 405, 501]);
|
|
7
|
+
function malformed(message) { return new AppError('malformed_response', `Invalid Batch API response: ${message}`); }
|
|
8
|
+
function errorData(error) {
|
|
9
|
+
return { code: error.code || 'network_error', message: error.message, ...(error.status ? { status: error.status } : {}) };
|
|
10
|
+
}
|
|
11
|
+
function normalizeRequests(sdk, requests) {
|
|
12
|
+
if (!Array.isArray(requests)) throw errUsage('Batch requests must be an array.');
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
return requests.map((item, index) => {
|
|
15
|
+
if (!item || typeof item !== 'object') throw errUsage('Each batch request must be an object.');
|
|
16
|
+
const id = item.id == null ? String(index + 1) : String(item.id);
|
|
17
|
+
if (!id || ids.has(id)) throw errUsage('Batch request IDs must be nonempty and unique.');
|
|
18
|
+
ids.add(id);
|
|
19
|
+
if (String(item.method || 'GET').toUpperCase() !== 'GET' || item.body !== undefined) {
|
|
20
|
+
throw errUsage('SDK batch supports GET reads only; send writes through ordinary SDK methods with their existing safety and history contracts.');
|
|
21
|
+
}
|
|
22
|
+
if (typeof item.url !== 'string' || !item.url.trim()) throw errUsage('Each batch request needs a same-instance API URL.');
|
|
23
|
+
let url;
|
|
24
|
+
try { url = new URL(item.url, `${sdk.baseURL}/`); } catch { throw errUsage('Invalid batch API URL.'); }
|
|
25
|
+
if (url.origin !== new URL(sdk.baseURL).origin || url.username || url.password || url.hash
|
|
26
|
+
|| !url.pathname.startsWith('/api/') || /\/batch\/?$/.test(url.pathname)) {
|
|
27
|
+
throw errUsage('Batch URLs must target a same-instance API endpoint; nested batches, credentials, and fragments are unsupported.');
|
|
28
|
+
}
|
|
29
|
+
const headers = new Headers(item.headers || {});
|
|
30
|
+
for (const name of headers.keys()) {
|
|
31
|
+
if (/^(authorization|cookie|x-usertoken|host|proxy-authorization)$/i.test(name)) throw errUsage('Batch subrequests cannot override authentication headers.');
|
|
32
|
+
}
|
|
33
|
+
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
|
34
|
+
if (sdk.domain) headers.set('X-Now-Domain', sdk.domain);
|
|
35
|
+
return { id, method: 'GET', url: url.pathname + url.search, headers: [...headers].map(([name, value]) => ({ name, value })), exclude_response_headers: true };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function decodeBody(body) {
|
|
39
|
+
if (body == null || body === '') return null;
|
|
40
|
+
if (typeof body !== 'string') throw malformed('item body must be base64 text');
|
|
41
|
+
const encoded = body.replace(/\s/g, '');
|
|
42
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw malformed('item body is not valid base64');
|
|
43
|
+
const text = Buffer.from(encoded, 'base64').toString('utf8');
|
|
44
|
+
if (!text) return null;
|
|
45
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
46
|
+
}
|
|
47
|
+
function parseBatch(response, requests, batchId) {
|
|
48
|
+
// ServiceNow's endpoint returns these arrays directly, not in a Table API
|
|
49
|
+
// result wrapper. Missing/duplicated correlations are never inferred by order.
|
|
50
|
+
if (!response || response.batch_request_id !== batchId || !Array.isArray(response.serviced_requests) || !Array.isArray(response.unserviced_requests)) {
|
|
51
|
+
throw malformed('expected matching batch_request_id and serviced/unserviced arrays');
|
|
52
|
+
}
|
|
53
|
+
const expected = new Set(requests.map(item => item.id));
|
|
54
|
+
const mapped = new Map();
|
|
55
|
+
for (const id of response.unserviced_requests) {
|
|
56
|
+
if (typeof id !== 'string' || !expected.has(id) || mapped.has(id)) throw malformed('unknown or duplicate unserviced ID');
|
|
57
|
+
mapped.set(id, { id, ok: false, serviced: false, status: null, data: null,
|
|
58
|
+
error: { code: 'batch_unserviced', message: 'The batch processing limit left this request unserviced; it was not retried.' } });
|
|
59
|
+
}
|
|
60
|
+
for (const item of response.serviced_requests) {
|
|
61
|
+
if (!item || !expected.has(item.id) || mapped.has(item.id)) throw malformed('unknown or duplicate serviced ID');
|
|
62
|
+
const status = item.status_code;
|
|
63
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) throw malformed('invalid item HTTP status');
|
|
64
|
+
const ok = status >= 200 && status < 300;
|
|
65
|
+
let data;
|
|
66
|
+
try { data = decodeBody(item.body); } catch (error) {
|
|
67
|
+
mapped.set(item.id, { id: item.id, ok: false, serviced: true, status, data: null, error: errorData(error) });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const error = ok ? null : errorData(errAPI(status, typeof data === 'string' ? data : JSON.stringify(data || { error: item.error_message || item.status_text || 'Request failed' })));
|
|
71
|
+
mapped.set(item.id, { id: item.id, ok, serviced: true, status, data, error });
|
|
72
|
+
}
|
|
73
|
+
return requests.map(({ id }) => mapped.get(id) || { id, ok: false, serviced: null, status: null, data: null,
|
|
74
|
+
error: { code: 'batch_missing_response', message: 'No correlated response was returned; execution is unknown and was not retried.' } });
|
|
75
|
+
}
|
|
76
|
+
async function individual(sdk, request, timeout) {
|
|
77
|
+
try {
|
|
78
|
+
return await sdk._withAuthenticatedRequest(`${sdk.baseURL}${request.url}`, {
|
|
79
|
+
method: 'GET', headers: Object.fromEntries(request.headers.map(({ name, value }) => [name, value])),
|
|
80
|
+
}, { timeout, normalizeErrors: true }, async response => {
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
let data = text || null;
|
|
83
|
+
if (text) { try { data = JSON.parse(text); } catch { /* Non-JSON REST reads remain text. */ } }
|
|
84
|
+
return { id: request.id, ok: response.ok, serviced: true, status: response.status, data,
|
|
85
|
+
error: response.ok ? null : errorData(errAPI(response.status, text)) };
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { id: request.id, ok: false, serviced: null, status: error.status || null, data: null, error: errorData(error) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {SDKClient} sdk authenticated SDK instance
|
|
94
|
+
* @param {Array<{id?: string, method?: 'GET', url: string, headers?: object}>} requests
|
|
95
|
+
* @param {{fallback?: boolean, chunkSize?: number, timeout?: number}} options
|
|
96
|
+
*/
|
|
97
|
+
export async function batchRead(sdk, requests, { fallback = true, chunkSize = 50, timeout = sdk.timeout } = {}) {
|
|
98
|
+
if (typeof fallback !== 'boolean') throw errUsage('Batch fallback must be a boolean.');
|
|
99
|
+
const normalized = normalizeRequests(sdk, requests);
|
|
100
|
+
if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > 1000) throw errUsage('Batch chunkSize must be an integer from 1 to 1000.');
|
|
101
|
+
const results = [], transports = new Set();
|
|
102
|
+
for (let offset = 0; offset < normalized.length; offset += chunkSize) {
|
|
103
|
+
const chunk = normalized.slice(offset, offset + chunkSize);
|
|
104
|
+
let useIndividual = fallback && sdk._batchUnsupported === true;
|
|
105
|
+
if (!useIndividual) {
|
|
106
|
+
const batchId = randomUUID();
|
|
107
|
+
try {
|
|
108
|
+
const response = await sdk.request(`${sdk.baseURL}/api/now/v1/batch`, {
|
|
109
|
+
method: 'POST', timeout, touchLastSeen: false,
|
|
110
|
+
body: JSON.stringify({ batch_request_id: batchId, rest_requests: chunk }),
|
|
111
|
+
});
|
|
112
|
+
results.push(...parseBatch(response, chunk, batchId));
|
|
113
|
+
transports.add('batch');
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Only explicit endpoint absence permits fallback. ACL/auth, rate limits,
|
|
116
|
+
// malformed responses and uncertain transport failures must not replay.
|
|
117
|
+
if (!fallback || !UNSUPPORTED.has(error.status)) throw error;
|
|
118
|
+
sdk._batchUnsupported = true;
|
|
119
|
+
useIndividual = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (useIndividual) {
|
|
123
|
+
transports.add('individual');
|
|
124
|
+
for (const request of chunk) results.push(await individual(sdk, request, timeout));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const successful = results.filter(item => item.ok).length;
|
|
128
|
+
return { transport: transports.size > 1 ? 'mixed' : [...transports][0] || 'none',
|
|
129
|
+
results, counts: { total: results.length, successful, failed: results.length - successful } };
|
|
130
|
+
}
|
package/src/sdk.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// ServiceNow REST API client
|
|
2
2
|
|
|
3
|
+
import { batchRead } from './sdk-batch.js';
|
|
3
4
|
import { gunzipSync } from 'node:zlib';
|
|
4
5
|
import { CodeAuth, errAuth, errAPI, errNetwork } from './errors.js';
|
|
5
6
|
import { getStringField } from './helpers.js';
|
|
@@ -241,6 +242,11 @@ export class SDKClient {
|
|
|
241
242
|
});
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
// Explicit opt-in; ordinary SDK methods remain unbatched.
|
|
246
|
+
async batch(requests, options = {}) {
|
|
247
|
+
return batchRead(this, requests, options);
|
|
248
|
+
}
|
|
249
|
+
|
|
244
250
|
async getSkippedUpdateBaseline(sysID) {
|
|
245
251
|
return readSkippedBaseline(this, sysID);
|
|
246
252
|
}
|