botversion-sdk 2.1.1 → 2.2.2
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/client.js +32 -9
- package/index.js +3 -0
- package/interceptor.js +535 -7
- package/package.json +1 -1
- package/scanner.js +588 -78
package/client.js
CHANGED
|
@@ -19,6 +19,8 @@ function BotVersionClient(options) {
|
|
|
19
19
|
this._queue = [];
|
|
20
20
|
this._flushTimer = null;
|
|
21
21
|
this._flushDelay = options.flushDelay || 3000;
|
|
22
|
+
// Tracks in-flight updates per endpoint so they are sent one after another
|
|
23
|
+
this._inflight = {};
|
|
22
24
|
var self = this;
|
|
23
25
|
process.on("beforeExit", function () {
|
|
24
26
|
if (self._queue.length > 0) self._flush();
|
|
@@ -74,13 +76,10 @@ BotVersionClient.prototype._flush = function () {
|
|
|
74
76
|
var toSend = self._queue.slice();
|
|
75
77
|
self._queue = [];
|
|
76
78
|
|
|
77
|
-
self
|
|
78
|
-
.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
})
|
|
82
|
-
.then(function (data) {})
|
|
83
|
-
.catch(function (err) {});
|
|
79
|
+
self._post("/api/sdk/register-endpoints", {
|
|
80
|
+
workspaceKey: self.apiKey,
|
|
81
|
+
endpoints: toSend,
|
|
82
|
+
});
|
|
84
83
|
};
|
|
85
84
|
|
|
86
85
|
/**
|
|
@@ -89,14 +88,38 @@ BotVersionClient.prototype._flush = function () {
|
|
|
89
88
|
BotVersionClient.prototype.updateEndpoint = function (endpoint) {
|
|
90
89
|
var self = this;
|
|
91
90
|
|
|
92
|
-
|
|
91
|
+
var payload = {
|
|
93
92
|
workspaceKey: self.apiKey,
|
|
94
93
|
method: endpoint.method,
|
|
95
94
|
path: endpoint.path,
|
|
96
95
|
requestBody: endpoint.requestBody || null,
|
|
97
96
|
responseBody: endpoint.responseBody || null,
|
|
98
97
|
detectedBy: endpoint.detectedBy || "runtime",
|
|
99
|
-
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Updates for the same endpoint go out one after another, so a later update
|
|
101
|
+
// (e.g. its reply shape) can never race the first registration of that endpoint.
|
|
102
|
+
var key = String(endpoint.method).toUpperCase() + ":" + endpoint.path;
|
|
103
|
+
var previous = self._inflight[key] || Promise.resolve();
|
|
104
|
+
var send = function () {
|
|
105
|
+
return self._post("/api/sdk/update-endpoint", payload).then(
|
|
106
|
+
function (res) {
|
|
107
|
+
return res;
|
|
108
|
+
},
|
|
109
|
+
function (err) {
|
|
110
|
+
throw err;
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
var current = previous.then(send, send);
|
|
115
|
+
self._inflight[key] = current;
|
|
116
|
+
|
|
117
|
+
var cleanup = function () {
|
|
118
|
+
if (self._inflight[key] === current) delete self._inflight[key];
|
|
119
|
+
};
|
|
120
|
+
current.then(cleanup, cleanup);
|
|
121
|
+
|
|
122
|
+
return current;
|
|
100
123
|
};
|
|
101
124
|
|
|
102
125
|
BotVersionClient.prototype.registerRoutePatterns = function (
|
package/index.js
CHANGED
|
@@ -226,6 +226,9 @@ function runFullScan(
|
|
|
226
226
|
if (shouldScanBackend) {
|
|
227
227
|
var endpoints = runBackendScanner(detectedBackend, app, options, cwd);
|
|
228
228
|
result.endpointCount = endpoints.length;
|
|
229
|
+
endpoints.filter(function (e) {
|
|
230
|
+
return e.method === "GET" || e.method === "DELETE";
|
|
231
|
+
});
|
|
229
232
|
|
|
230
233
|
if (endpoints.length > 0) {
|
|
231
234
|
client.registerEndpoints(endpoints);
|
package/interceptor.js
CHANGED
|
@@ -3,6 +3,71 @@
|
|
|
3
3
|
|
|
4
4
|
const reportedEndpoints = new Set();
|
|
5
5
|
|
|
6
|
+
// ── Query-string capture (GET / DELETE) ──────────────────────────────────
|
|
7
|
+
// Records only the NAMES of query keys, never their values.
|
|
8
|
+
const seenQueryVariants = new Set();
|
|
9
|
+
const queryVariantCounts = {};
|
|
10
|
+
const MAX_QUERY_VARIANTS = 5; // different key sets remembered per endpoint
|
|
11
|
+
const MAX_QUERY_KEYS = 20;
|
|
12
|
+
const QUERY_NOISE_KEYS = new Set([
|
|
13
|
+
"t",
|
|
14
|
+
"ts",
|
|
15
|
+
"timestamp",
|
|
16
|
+
"cb",
|
|
17
|
+
"cachebust",
|
|
18
|
+
"nocache",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function buildQueryStructure(method, rawUrl, rawPath) {
|
|
22
|
+
if (method !== "GET" && method !== "DELETE") return null;
|
|
23
|
+
if (String(rawPath || "").indexOf("/trpc/") !== -1) return null; // tRPC has its own input handling
|
|
24
|
+
const url = String(rawUrl || "");
|
|
25
|
+
const q = url.indexOf("?");
|
|
26
|
+
if (q === -1) return null;
|
|
27
|
+
let params;
|
|
28
|
+
try {
|
|
29
|
+
params = new URLSearchParams(url.slice(q + 1).split("#")[0]);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const structure = {};
|
|
34
|
+
let count = 0;
|
|
35
|
+
params.forEach(function (_value, rawKey) {
|
|
36
|
+
if (count >= MAX_QUERY_KEYS) return;
|
|
37
|
+
const key = rawKey.replace(/\[\]$/, "");
|
|
38
|
+
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key)) return;
|
|
39
|
+
if (QUERY_NOISE_KEYS.has(key.toLowerCase()) || looksLikeIdKey(key)) return;
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(structure, key)) return;
|
|
41
|
+
structure[key] = "string";
|
|
42
|
+
count++;
|
|
43
|
+
});
|
|
44
|
+
return count > 0 ? structure : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Returns the query key names for this call, or null (also once an endpoint has too many variants)
|
|
48
|
+
function resolveQueryStructure(baseKey, method, rawUrl, rawPath) {
|
|
49
|
+
const structure = buildQueryStructure(method, rawUrl, rawPath);
|
|
50
|
+
if (!structure) return null;
|
|
51
|
+
const variant = baseKey + queryKeySuffix(structure);
|
|
52
|
+
if (seenQueryVariants.has(variant)) return structure;
|
|
53
|
+
const used = queryVariantCounts[baseKey] || 0;
|
|
54
|
+
if (used >= MAX_QUERY_VARIANTS) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
queryVariantCounts[baseKey] = used + 1;
|
|
58
|
+
seenQueryVariants.add(variant);
|
|
59
|
+
return structure;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function queryKeySuffix(structure) {
|
|
63
|
+
return structure ? ":q:" + Object.keys(structure).sort().join(",") : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mergeQueryIntoStructure(bodyStructure, queryStructure) {
|
|
67
|
+
if (!queryStructure) return bodyStructure;
|
|
68
|
+
return Object.assign({}, queryStructure, bodyStructure || {});
|
|
69
|
+
}
|
|
70
|
+
|
|
6
71
|
function structureToJsonSchema(bodyStructure) {
|
|
7
72
|
if (!bodyStructure) return null;
|
|
8
73
|
return {
|
|
@@ -26,6 +91,339 @@ function structureToJsonSchema(bodyStructure) {
|
|
|
26
91
|
};
|
|
27
92
|
}
|
|
28
93
|
|
|
94
|
+
// ── Response shape capture ───────────────────────────────────────────────
|
|
95
|
+
// Records only field names and types of successful JSON replies (never values)
|
|
96
|
+
// so the platform knows what each endpoint returns.
|
|
97
|
+
const reportedResponses = new Set();
|
|
98
|
+
// Stops watching an endpoint after a few replies that yielded no usable shape
|
|
99
|
+
const responseAttempts = {};
|
|
100
|
+
const MAX_RESPONSE_ATTEMPTS = 5;
|
|
101
|
+
const MAX_RESPONSE_CAPTURE_BYTES = 256 * 1024; // stop buffering bigger replies
|
|
102
|
+
const MAX_RESPONSE_DEPTH = 4;
|
|
103
|
+
const MAX_RESPONSE_FIELDS = 50;
|
|
104
|
+
|
|
105
|
+
// Keys that look like record IDs or emails are never sent as field names
|
|
106
|
+
// (guards against objects keyed by IDs leaking them as "fields").
|
|
107
|
+
function looksLikeIdKey(key) {
|
|
108
|
+
return (
|
|
109
|
+
/^\d+$/.test(key) ||
|
|
110
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
111
|
+
key,
|
|
112
|
+
) ||
|
|
113
|
+
/^[0-9a-f]{24}$/i.test(key) ||
|
|
114
|
+
/^c[a-z0-9]{20,}$/i.test(key) ||
|
|
115
|
+
(key.length >= 16 && /[a-zA-Z]/.test(key) && /[0-9]/.test(key)) ||
|
|
116
|
+
key.includes("@")
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function describeResponseValue(val, depth) {
|
|
121
|
+
if (val === null || val === undefined) return { type: "string" };
|
|
122
|
+
if (Array.isArray(val)) {
|
|
123
|
+
if (depth >= MAX_RESPONSE_DEPTH || val.length === 0) {
|
|
124
|
+
return { type: "array", items: { type: "object" } };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
type: "array",
|
|
128
|
+
items: describeArrayItems(val.slice(0, 5), depth + 1),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (typeof val === "object") {
|
|
132
|
+
const keys = Object.keys(val);
|
|
133
|
+
if (depth >= MAX_RESPONSE_DEPTH || keys.some(looksLikeIdKey)) {
|
|
134
|
+
return { type: "object" };
|
|
135
|
+
}
|
|
136
|
+
const properties = {};
|
|
137
|
+
keys.slice(0, MAX_RESPONSE_FIELDS).forEach(function (k) {
|
|
138
|
+
properties[k] = describeResponseValue(val[k], depth + 1);
|
|
139
|
+
});
|
|
140
|
+
return { type: "object", properties: properties };
|
|
141
|
+
}
|
|
142
|
+
if (typeof val === "number" || typeof val === "boolean") {
|
|
143
|
+
return { type: typeof val };
|
|
144
|
+
}
|
|
145
|
+
return { type: "string" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Combines the first few list items so fields missing from one item still appear
|
|
149
|
+
function describeArrayItems(items, depth) {
|
|
150
|
+
const objs = items.filter(function (i) {
|
|
151
|
+
return i && typeof i === "object" && !Array.isArray(i);
|
|
152
|
+
});
|
|
153
|
+
if (objs.length === 0) {
|
|
154
|
+
const first = items.find(function (i) {
|
|
155
|
+
return i !== null && i !== undefined;
|
|
156
|
+
});
|
|
157
|
+
return first === undefined
|
|
158
|
+
? { type: "object" }
|
|
159
|
+
: describeResponseValue(first, depth);
|
|
160
|
+
}
|
|
161
|
+
const merged = {};
|
|
162
|
+
objs.forEach(function (o) {
|
|
163
|
+
Object.keys(o)
|
|
164
|
+
.slice(0, MAX_RESPONSE_FIELDS)
|
|
165
|
+
.forEach(function (k) {
|
|
166
|
+
if (merged[k] === undefined || merged[k] === null) merged[k] = o[k];
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
return describeResponseValue(merged, depth);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A shape with no fields (empty list, {}) is not useful — keep waiting for a better reply
|
|
173
|
+
function hasResponseFields(schema) {
|
|
174
|
+
if (!schema) return false;
|
|
175
|
+
if (schema.type === "array") return hasResponseFields(schema.items);
|
|
176
|
+
return !!(schema.properties && Object.keys(schema.properties).length > 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// tRPC wraps each result as { result: { data: { json: ... } } }; errors as { error }
|
|
180
|
+
function unwrapTrpcResult(entry) {
|
|
181
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
182
|
+
if (entry.error && !entry.result) return undefined;
|
|
183
|
+
if (
|
|
184
|
+
entry.result &&
|
|
185
|
+
typeof entry.result === "object" &&
|
|
186
|
+
"data" in entry.result
|
|
187
|
+
) {
|
|
188
|
+
const data = entry.result.data;
|
|
189
|
+
return data &&
|
|
190
|
+
typeof data === "object" &&
|
|
191
|
+
!Array.isArray(data) &&
|
|
192
|
+
"json" in data
|
|
193
|
+
? data.json
|
|
194
|
+
: data;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return entry;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Parses the captured reply; also handles replies compressed by the host app
|
|
201
|
+
function parseCapturedBody(buffer, res) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
204
|
+
} catch (e) {}
|
|
205
|
+
try {
|
|
206
|
+
const zlib = require("zlib");
|
|
207
|
+
const enc = String(res.getHeader("content-encoding") || "").toLowerCase();
|
|
208
|
+
const opts = { maxOutputLength: 1024 * 1024 };
|
|
209
|
+
let raw = null;
|
|
210
|
+
if (enc.includes("gzip")) raw = zlib.gunzipSync(buffer, opts);
|
|
211
|
+
else if (enc.includes("br")) raw = zlib.brotliDecompressSync(buffer, opts);
|
|
212
|
+
else if (enc.includes("deflate")) raw = zlib.inflateSync(buffer, opts);
|
|
213
|
+
if (raw) return JSON.parse(raw.toString("utf8"));
|
|
214
|
+
} catch (e) {}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Our own scan-trigger route must never be reported as one of the host's endpoints
|
|
219
|
+
function isBotVersionInternalPath(path) {
|
|
220
|
+
return String(path || "").indexOf("/__botversion/") === 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// True while at least one endpoint behind this path still has no captured reply shape
|
|
224
|
+
function isResponseShapeNeeded(method, rawPath) {
|
|
225
|
+
if (isBotVersionInternalPath(rawPath)) return false;
|
|
226
|
+
return splitBatchPath(rawPath)
|
|
227
|
+
.map(normalizePath)
|
|
228
|
+
.some(function (p) {
|
|
229
|
+
const k = method + ":" + p;
|
|
230
|
+
return (
|
|
231
|
+
!reportedResponses.has(k) &&
|
|
232
|
+
(responseAttempts[k] || 0) < MAX_RESPONSE_ATTEMPTS
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Turns an already-parsed reply into field-name shapes and reports each new one
|
|
238
|
+
function reportResponseShape(client, method, rawPath, parsed) {
|
|
239
|
+
try {
|
|
240
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
241
|
+
|
|
242
|
+
const isTrpc = rawPath.includes("/trpc/");
|
|
243
|
+
const paths = splitBatchPath(rawPath).map(normalizePath);
|
|
244
|
+
|
|
245
|
+
let slots;
|
|
246
|
+
if (paths.length > 1) {
|
|
247
|
+
if (!isTrpc || !Array.isArray(parsed) || parsed.length !== paths.length)
|
|
248
|
+
return;
|
|
249
|
+
slots = parsed.map(unwrapTrpcResult);
|
|
250
|
+
} else {
|
|
251
|
+
slots = [isTrpc ? unwrapTrpcResult(parsed) : parsed];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
paths.forEach(function (p, i) {
|
|
255
|
+
const key = method + ":" + p;
|
|
256
|
+
if (reportedResponses.has(key)) return;
|
|
257
|
+
const slot = slots[i];
|
|
258
|
+
if (!slot || typeof slot !== "object") return;
|
|
259
|
+
|
|
260
|
+
const schema = describeResponseValue(slot, 0);
|
|
261
|
+
if (!hasResponseFields(schema)) return;
|
|
262
|
+
|
|
263
|
+
reportedResponses.add(key);
|
|
264
|
+
client
|
|
265
|
+
.updateEndpoint({
|
|
266
|
+
method: method,
|
|
267
|
+
path: p,
|
|
268
|
+
requestBody: null,
|
|
269
|
+
responseBody: schema,
|
|
270
|
+
detectedBy: "runtime",
|
|
271
|
+
})
|
|
272
|
+
.catch(function () {});
|
|
273
|
+
});
|
|
274
|
+
} catch (e) {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// For frameworks that expose the reply body before sending it (Koa, Hapi)
|
|
278
|
+
function reportFrameworkBody(
|
|
279
|
+
client,
|
|
280
|
+
method,
|
|
281
|
+
rawPath,
|
|
282
|
+
statusCode,
|
|
283
|
+
contentType,
|
|
284
|
+
body,
|
|
285
|
+
) {
|
|
286
|
+
// Deferred so it is always sent after the endpoint's first registration
|
|
287
|
+
setImmediate(function () {
|
|
288
|
+
reportFrameworkBodyNow(
|
|
289
|
+
client,
|
|
290
|
+
method,
|
|
291
|
+
rawPath,
|
|
292
|
+
statusCode,
|
|
293
|
+
contentType,
|
|
294
|
+
body,
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function reportFrameworkBodyNow(
|
|
300
|
+
client,
|
|
301
|
+
method,
|
|
302
|
+
rawPath,
|
|
303
|
+
statusCode,
|
|
304
|
+
contentType,
|
|
305
|
+
body,
|
|
306
|
+
) {
|
|
307
|
+
try {
|
|
308
|
+
if (statusCode < 200 || statusCode >= 300) return;
|
|
309
|
+
if (!isResponseShapeNeeded(method, rawPath)) return;
|
|
310
|
+
|
|
311
|
+
let text;
|
|
312
|
+
if (typeof body === "string" || Buffer.isBuffer(body)) {
|
|
313
|
+
const ct = String(contentType || "").toLowerCase();
|
|
314
|
+
if (ct && !ct.includes("json")) return;
|
|
315
|
+
text = Buffer.isBuffer(body) ? body.toString("utf8") : body;
|
|
316
|
+
} else if (
|
|
317
|
+
body &&
|
|
318
|
+
typeof body === "object" &&
|
|
319
|
+
typeof body.pipe !== "function"
|
|
320
|
+
) {
|
|
321
|
+
// Round-trip so we see exactly what is sent (handles toJSON, dates, class instances)
|
|
322
|
+
text = JSON.stringify(body);
|
|
323
|
+
} else {
|
|
324
|
+
return; // streams and empty replies cannot be read
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!text || text.length > MAX_RESPONSE_CAPTURE_BYTES) return;
|
|
328
|
+
reportResponseShape(client, method, rawPath, JSON.parse(text));
|
|
329
|
+
} catch (e) {}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Watches one Node reply (Express and the plain http server) and, if it is a
|
|
333
|
+
// successful JSON reply, reports its shape. Never changes the reply and never
|
|
334
|
+
// throws into the host app.
|
|
335
|
+
function watchJsonResponse(res, client, method, rawPath, req) {
|
|
336
|
+
try {
|
|
337
|
+
if (
|
|
338
|
+
!res ||
|
|
339
|
+
typeof res.write !== "function" ||
|
|
340
|
+
typeof res.once !== "function"
|
|
341
|
+
)
|
|
342
|
+
return;
|
|
343
|
+
if (!isResponseShapeNeeded(method, rawPath)) return; // no overhead once captured
|
|
344
|
+
|
|
345
|
+
const chunks = [];
|
|
346
|
+
let size = 0;
|
|
347
|
+
let skip = false;
|
|
348
|
+
|
|
349
|
+
// Drops cache-validation headers so the app sends a full reply instead of an empty 304
|
|
350
|
+
// (which has no body to read). Only runs while this endpoint still has no captured shape.
|
|
351
|
+
try {
|
|
352
|
+
if (req && req.headers) {
|
|
353
|
+
delete req.headers["if-none-match"];
|
|
354
|
+
delete req.headers["if-modified-since"];
|
|
355
|
+
}
|
|
356
|
+
} catch (e) {}
|
|
357
|
+
|
|
358
|
+
function collect(chunk, encoding) {
|
|
359
|
+
if (
|
|
360
|
+
skip ||
|
|
361
|
+
chunk === undefined ||
|
|
362
|
+
chunk === null ||
|
|
363
|
+
typeof chunk === "function"
|
|
364
|
+
)
|
|
365
|
+
return;
|
|
366
|
+
try {
|
|
367
|
+
if (chunks.length === 0) {
|
|
368
|
+
const ct = String(res.getHeader("content-type") || "").toLowerCase();
|
|
369
|
+
if (ct && !ct.includes("json")) {
|
|
370
|
+
skip = true;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const buf =
|
|
375
|
+
typeof chunk === "string"
|
|
376
|
+
? Buffer.from(
|
|
377
|
+
chunk,
|
|
378
|
+
typeof encoding === "string" ? encoding : "utf8",
|
|
379
|
+
)
|
|
380
|
+
: Buffer.from(chunk);
|
|
381
|
+
size += buf.length;
|
|
382
|
+
if (size > MAX_RESPONSE_CAPTURE_BYTES) {
|
|
383
|
+
skip = true;
|
|
384
|
+
chunks.length = 0;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
chunks.push(buf);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
skip = true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const originalWrite = res.write;
|
|
394
|
+
const originalEnd = res.end;
|
|
395
|
+
res.write = function (chunk, encoding) {
|
|
396
|
+
collect(chunk, encoding);
|
|
397
|
+
return originalWrite.apply(this, arguments);
|
|
398
|
+
};
|
|
399
|
+
res.end = function (chunk, encoding) {
|
|
400
|
+
collect(chunk, encoding);
|
|
401
|
+
return originalEnd.apply(this, arguments);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
res.once("finish", function () {
|
|
405
|
+
if (res.statusCode < 200 || res.statusCode >= 300) return;
|
|
406
|
+
|
|
407
|
+
// Counts each successful reply so endpoints with no usable shape are eventually dropped
|
|
408
|
+
splitBatchPath(rawPath)
|
|
409
|
+
.map(normalizePath)
|
|
410
|
+
.forEach(function (p) {
|
|
411
|
+
const k = method + ":" + p;
|
|
412
|
+
responseAttempts[k] = (responseAttempts[k] || 0) + 1;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (skip || chunks.length === 0) return;
|
|
416
|
+
|
|
417
|
+
setImmediate(function () {
|
|
418
|
+
try {
|
|
419
|
+
const parsed = parseCapturedBody(Buffer.concat(chunks), res);
|
|
420
|
+
reportResponseShape(client, method, rawPath, parsed);
|
|
421
|
+
} catch (e) {}
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
} catch (e) {}
|
|
425
|
+
}
|
|
426
|
+
|
|
29
427
|
/**
|
|
30
428
|
* Attaches a middleware to the Express app that
|
|
31
429
|
* silently intercepts every request and reports
|
|
@@ -104,7 +502,16 @@ function attachInterceptor(app, client, options) {
|
|
|
104
502
|
const endpointKey = method + ":" + normalizedPath;
|
|
105
503
|
const slotBody = bodySlots[i] || null;
|
|
106
504
|
|
|
107
|
-
|
|
505
|
+
// GET/DELETE inputs live in the query string, so their names are added too
|
|
506
|
+
const bodyStructure = mergeQueryIntoStructure(
|
|
507
|
+
buildBodyStructure(slotBody),
|
|
508
|
+
resolveQueryStructure(
|
|
509
|
+
endpointKey,
|
|
510
|
+
method,
|
|
511
|
+
req.originalUrl || req.url || "",
|
|
512
|
+
singlePath,
|
|
513
|
+
),
|
|
514
|
+
);
|
|
108
515
|
const bodyKey =
|
|
109
516
|
endpointKey +
|
|
110
517
|
":" +
|
|
@@ -130,6 +537,9 @@ function attachInterceptor(app, client, options) {
|
|
|
130
537
|
}
|
|
131
538
|
});
|
|
132
539
|
|
|
540
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
541
|
+
watchJsonResponse(res, client, method, path, req);
|
|
542
|
+
|
|
133
543
|
next();
|
|
134
544
|
});
|
|
135
545
|
}
|
|
@@ -426,8 +836,18 @@ function attachNextJsInterceptor(client, options) {
|
|
|
426
836
|
const isApiPath = path.startsWith(options.apiPrefix || "/api");
|
|
427
837
|
|
|
428
838
|
if (!shouldIgnore && isApiPath) {
|
|
839
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
840
|
+
watchJsonResponse(res, client, method, path, req);
|
|
429
841
|
const normalizedPath = normalizePath(path);
|
|
430
|
-
const
|
|
842
|
+
const baseKey = method + ":" + normalizedPath;
|
|
843
|
+
// GET/DELETE inputs live in the query string, so their names are tracked too
|
|
844
|
+
const queryStructure = resolveQueryStructure(
|
|
845
|
+
baseKey,
|
|
846
|
+
method,
|
|
847
|
+
req.url || "",
|
|
848
|
+
path,
|
|
849
|
+
);
|
|
850
|
+
const endpointKey = baseKey + queryKeySuffix(queryStructure);
|
|
431
851
|
|
|
432
852
|
if (!reportedEndpoints.has(endpointKey)) {
|
|
433
853
|
reportedEndpoints.add(endpointKey);
|
|
@@ -510,7 +930,10 @@ function attachNextJsInterceptor(client, options) {
|
|
|
510
930
|
splitPaths.forEach(function (singlePath, i) {
|
|
511
931
|
const slotBody = bodySlots[i] || null;
|
|
512
932
|
const parsedBody = structureToJsonSchema(
|
|
513
|
-
|
|
933
|
+
mergeQueryIntoStructure(
|
|
934
|
+
buildBodyStructure(slotBody),
|
|
935
|
+
splitPaths.length === 1 ? queryStructure : null,
|
|
936
|
+
),
|
|
514
937
|
);
|
|
515
938
|
client
|
|
516
939
|
.updateEndpoint({
|
|
@@ -637,7 +1060,15 @@ function attachFastifyInterceptor(fastify, client, options) {
|
|
|
637
1060
|
if (options.apiPrefix && !path.startsWith(options.apiPrefix)) return;
|
|
638
1061
|
|
|
639
1062
|
const normalizedPath = normalizePath(path);
|
|
640
|
-
const
|
|
1063
|
+
const baseKey = method + ":" + normalizedPath;
|
|
1064
|
+
// GET/DELETE inputs live in the query string, so their names are reported too
|
|
1065
|
+
const queryStructure = resolveQueryStructure(
|
|
1066
|
+
baseKey,
|
|
1067
|
+
method,
|
|
1068
|
+
request.url || "",
|
|
1069
|
+
path,
|
|
1070
|
+
);
|
|
1071
|
+
const endpointKey = baseKey + queryKeySuffix(queryStructure);
|
|
641
1072
|
|
|
642
1073
|
if (!reportedEndpoints.has(endpointKey)) {
|
|
643
1074
|
reportedEndpoints.add(endpointKey);
|
|
@@ -647,7 +1078,9 @@ function attachFastifyInterceptor(fastify, client, options) {
|
|
|
647
1078
|
.updateEndpoint({
|
|
648
1079
|
method,
|
|
649
1080
|
path: normalizedPath,
|
|
650
|
-
requestBody:
|
|
1081
|
+
requestBody: queryStructure
|
|
1082
|
+
? structureToJsonSchema(queryStructure)
|
|
1083
|
+
: null,
|
|
651
1084
|
detectedBy: "runtime-fastify",
|
|
652
1085
|
})
|
|
653
1086
|
.catch(function () {});
|
|
@@ -684,6 +1117,45 @@ function attachFastifyInterceptor(fastify, client, options) {
|
|
|
684
1117
|
.catch(function () {});
|
|
685
1118
|
});
|
|
686
1119
|
});
|
|
1120
|
+
|
|
1121
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
1122
|
+
fastify.addHook("onSend", async function (request, reply, payload) {
|
|
1123
|
+
try {
|
|
1124
|
+
const path = request.url ? request.url.split("?")[0] : "";
|
|
1125
|
+
const method = request.method ? request.method.toUpperCase() : "";
|
|
1126
|
+
|
|
1127
|
+
const shouldIgnore = ignorePaths.some(function (p) {
|
|
1128
|
+
return path.startsWith(p);
|
|
1129
|
+
});
|
|
1130
|
+
if (shouldIgnore) return payload;
|
|
1131
|
+
if (options.apiPrefix && !path.startsWith(options.apiPrefix))
|
|
1132
|
+
return payload;
|
|
1133
|
+
if (reply.statusCode < 200 || reply.statusCode >= 300) return payload;
|
|
1134
|
+
if (!isResponseShapeNeeded(method, path)) return payload;
|
|
1135
|
+
|
|
1136
|
+
const contentType = String(
|
|
1137
|
+
reply.getHeader("content-type") || "",
|
|
1138
|
+
).toLowerCase();
|
|
1139
|
+
if (contentType && !contentType.includes("json")) return payload;
|
|
1140
|
+
|
|
1141
|
+
// Only fully-built replies can be read; streams are skipped
|
|
1142
|
+
if (typeof payload !== "string" && !Buffer.isBuffer(payload))
|
|
1143
|
+
return payload;
|
|
1144
|
+
const buffer = Buffer.from(payload);
|
|
1145
|
+
if (buffer.length > MAX_RESPONSE_CAPTURE_BYTES) return payload;
|
|
1146
|
+
|
|
1147
|
+
const encoding = reply.getHeader("content-encoding");
|
|
1148
|
+
setImmediate(function () {
|
|
1149
|
+
const parsed = parseCapturedBody(buffer, {
|
|
1150
|
+
getHeader: function () {
|
|
1151
|
+
return encoding;
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
reportResponseShape(client, method, path, parsed);
|
|
1155
|
+
});
|
|
1156
|
+
} catch (e) {}
|
|
1157
|
+
return payload;
|
|
1158
|
+
});
|
|
687
1159
|
}
|
|
688
1160
|
|
|
689
1161
|
/**
|
|
@@ -748,7 +1220,16 @@ function attachKoaInterceptor(app, client, options) {
|
|
|
748
1220
|
const endpointKey = method + ":" + normalizedPath;
|
|
749
1221
|
|
|
750
1222
|
const body = ctx.request.body;
|
|
751
|
-
|
|
1223
|
+
// GET/DELETE inputs live in the query string, so their names are added too
|
|
1224
|
+
const bodyStructure = mergeQueryIntoStructure(
|
|
1225
|
+
buildBodyStructure(body),
|
|
1226
|
+
resolveQueryStructure(
|
|
1227
|
+
endpointKey,
|
|
1228
|
+
method,
|
|
1229
|
+
ctx.originalUrl || ctx.url || "",
|
|
1230
|
+
path,
|
|
1231
|
+
),
|
|
1232
|
+
);
|
|
752
1233
|
const bodyKey =
|
|
753
1234
|
endpointKey +
|
|
754
1235
|
":" +
|
|
@@ -772,6 +1253,10 @@ function attachKoaInterceptor(app, client, options) {
|
|
|
772
1253
|
.catch(function () {});
|
|
773
1254
|
});
|
|
774
1255
|
}
|
|
1256
|
+
|
|
1257
|
+
// Capture the shape of the successful JSON reply (field names only).
|
|
1258
|
+
// Kept last so it is queued after the endpoint's first registration above.
|
|
1259
|
+
reportFrameworkBody(client, method, path, ctx.status, ctx.type, ctx.body);
|
|
775
1260
|
});
|
|
776
1261
|
}
|
|
777
1262
|
|
|
@@ -840,7 +1325,16 @@ function attachHapiInterceptor(server, client, options) {
|
|
|
840
1325
|
const endpointKey = method + ":" + normalizedPath;
|
|
841
1326
|
|
|
842
1327
|
const body = request.payload;
|
|
843
|
-
|
|
1328
|
+
// GET/DELETE inputs live in the query string, so their names are added too
|
|
1329
|
+
const bodyStructure = mergeQueryIntoStructure(
|
|
1330
|
+
buildBodyStructure(body),
|
|
1331
|
+
resolveQueryStructure(
|
|
1332
|
+
endpointKey,
|
|
1333
|
+
method,
|
|
1334
|
+
path + ((request.url && request.url.search) || ""),
|
|
1335
|
+
path,
|
|
1336
|
+
),
|
|
1337
|
+
);
|
|
844
1338
|
const bodyKey =
|
|
845
1339
|
endpointKey +
|
|
846
1340
|
":" +
|
|
@@ -867,6 +1361,40 @@ function attachHapiInterceptor(server, client, options) {
|
|
|
867
1361
|
|
|
868
1362
|
return h.continue;
|
|
869
1363
|
});
|
|
1364
|
+
|
|
1365
|
+
// Capture the shape of the successful JSON reply (field names only)
|
|
1366
|
+
server.ext("onPreResponse", function (request, h) {
|
|
1367
|
+
try {
|
|
1368
|
+
const path = request.path || "";
|
|
1369
|
+
const method = request.method ? request.method.toUpperCase() : "";
|
|
1370
|
+
const response = request.response;
|
|
1371
|
+
|
|
1372
|
+
const shouldIgnore = ignorePaths.some(function (p) {
|
|
1373
|
+
return path.startsWith(p);
|
|
1374
|
+
});
|
|
1375
|
+
const outsidePrefix =
|
|
1376
|
+
options.apiPrefix && !path.startsWith(options.apiPrefix);
|
|
1377
|
+
|
|
1378
|
+
// Only plain replies (not errors, views or streams) carry a readable body
|
|
1379
|
+
if (
|
|
1380
|
+
!shouldIgnore &&
|
|
1381
|
+
!outsidePrefix &&
|
|
1382
|
+
response &&
|
|
1383
|
+
!response.isBoom &&
|
|
1384
|
+
response.variety === "plain"
|
|
1385
|
+
) {
|
|
1386
|
+
reportFrameworkBody(
|
|
1387
|
+
client,
|
|
1388
|
+
method,
|
|
1389
|
+
path,
|
|
1390
|
+
response.statusCode,
|
|
1391
|
+
response.headers && response.headers["content-type"],
|
|
1392
|
+
response.source,
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
} catch (e) {}
|
|
1396
|
+
return h.continue;
|
|
1397
|
+
});
|
|
870
1398
|
}
|
|
871
1399
|
|
|
872
1400
|
/**
|