swaggerizer 1.0.0 → 1.1.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/README.md +28 -1
- package/dist/admin.d.ts +2 -1
- package/dist/admin.d.ts.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +651 -33
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/config.mjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +654 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +651 -35
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.ts +9 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +30 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/app.css +10 -0
- package/dist/ui/app.js +25 -0
- package/dist/validate.d.ts +33 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +3 -1
- package/dist/chunks/scenario-5a1VnDmg.mjs +0 -345
- package/dist/chunks/scenario-5a1VnDmg.mjs.map +0 -1
- package/dist/chunks/scenario-BeXAfctR.mjs +0 -347
- package/dist/chunks/scenario-BeXAfctR.mjs.map +0 -1
- package/dist/chunks/scenario-CGR-NuEy.mjs +0 -371
- package/dist/chunks/scenario-CGR-NuEy.mjs.map +0 -1
- package/dist/chunks/scenario-DKCQMD5-.js +0 -364
- package/dist/chunks/scenario-DKCQMD5-.js.map +0 -1
- package/dist/chunks/scenario-yqSnVp0L.js +0 -362
- package/dist/chunks/scenario-yqSnVp0L.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -6,6 +6,9 @@ var http = require('node:http');
|
|
|
6
6
|
var path = require('node:path');
|
|
7
7
|
var node_url = require('node:url');
|
|
8
8
|
var SwaggerParser = require('@apidevtools/swagger-parser');
|
|
9
|
+
var Ajv = require('ajv');
|
|
10
|
+
var Ajv2020 = require('ajv/dist/2020');
|
|
11
|
+
var addFormats = require('ajv-formats');
|
|
9
12
|
var config = require('./config.js');
|
|
10
13
|
var os = require('node:os');
|
|
11
14
|
|
|
@@ -107,6 +110,23 @@ function sendJson(res, status, payload) {
|
|
|
107
110
|
function blank(value) {
|
|
108
111
|
return value === undefined || value === null || value === "";
|
|
109
112
|
}
|
|
113
|
+
/** MSW reports request summaries to the live log; keep only the fields the page renders. */
|
|
114
|
+
function toValidation(raw) {
|
|
115
|
+
if (!raw || typeof raw !== "object")
|
|
116
|
+
return undefined;
|
|
117
|
+
const text = (value, max = 200) => typeof value === "string" && value !== "" ? value.slice(0, max) : undefined;
|
|
118
|
+
const request = (Array.isArray(raw.request) ? raw.request : [])
|
|
119
|
+
.slice(0, 20)
|
|
120
|
+
.map((issue) => ({
|
|
121
|
+
location: text(issue?.location, 120) ?? "request",
|
|
122
|
+
message: text(issue?.message) ?? "is invalid",
|
|
123
|
+
...(text(issue?.keyword, 40) ? { keyword: text(issue?.keyword, 40) } : {})
|
|
124
|
+
}));
|
|
125
|
+
const skipped = text(raw.skipped, 60);
|
|
126
|
+
if (request.length === 0 && !skipped)
|
|
127
|
+
return undefined;
|
|
128
|
+
return { request, ...(skipped ? { skipped } : {}) };
|
|
129
|
+
}
|
|
110
130
|
/**
|
|
111
131
|
* Turns a flat record (query string, form post or JSON) into a selection. Accepts the ramlizer
|
|
112
132
|
* names (`nextResponseCode`, `nextExampleName`) and flat custom fields (`status`, `body`, `contentType`).
|
|
@@ -201,6 +221,10 @@ function registerAdmin(ctx) {
|
|
|
201
221
|
sendJson(res, 200, { changes: ctx.getState().changes });
|
|
202
222
|
return true;
|
|
203
223
|
}
|
|
224
|
+
if (method === "GET" && path === "/api/validation") {
|
|
225
|
+
sendJson(res, 200, ctx.getValidation());
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
204
228
|
if (path === "/api/log") {
|
|
205
229
|
if (method === "GET") {
|
|
206
230
|
const since = Number(query.get("since") || 0);
|
|
@@ -210,16 +234,20 @@ function registerAdmin(ctx) {
|
|
|
210
234
|
if (method === "POST") {
|
|
211
235
|
// Lets the MSW runtime report requests it answered in the browser or in tests.
|
|
212
236
|
const items = Array.isArray(req.body) ? req.body : [req.body];
|
|
213
|
-
const entries = items.slice(0, 50).map(item =>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
const entries = items.slice(0, 50).map(item => {
|
|
238
|
+
const validation = toValidation(item?.validation);
|
|
239
|
+
return ctx.recordRequest({
|
|
240
|
+
method: String(item?.method || "GET").toUpperCase(),
|
|
241
|
+
path: String(item?.path || ""),
|
|
242
|
+
...(item?.route ? { route: String(item.route) } : {}),
|
|
243
|
+
status: Number(item?.status) || 0,
|
|
244
|
+
example: String(item?.example || ""),
|
|
245
|
+
...(item?.specId ? { specId: String(item.specId) } : {}),
|
|
246
|
+
...(item?.ms !== undefined ? { ms: Number(item.ms) } : {}),
|
|
247
|
+
...(validation ? { validation } : {}),
|
|
248
|
+
source: "msw"
|
|
249
|
+
});
|
|
250
|
+
});
|
|
223
251
|
sendJson(res, 200, { recorded: entries.length });
|
|
224
252
|
return true;
|
|
225
253
|
}
|
|
@@ -1231,6 +1259,494 @@ async function resolveSpecInputs(inputs) {
|
|
|
1231
1259
|
return [...new Set(out)];
|
|
1232
1260
|
}
|
|
1233
1261
|
|
|
1262
|
+
/*
|
|
1263
|
+
* Request validation: checks a matched request against the operation's declared parameters and
|
|
1264
|
+
* request body, so a mock cannot answer `200` to a request the contract forbids.
|
|
1265
|
+
*
|
|
1266
|
+
* Node only. Ajv must never reach the browser entry points (`msw`, `debug-menu`, `config`), which is
|
|
1267
|
+
* why the shared types live in `./types` and this module is not re-exported from them.
|
|
1268
|
+
*
|
|
1269
|
+
* Specs are bundled but not dereferenced: every operation schema is compiled on its own, with its
|
|
1270
|
+
* internal `#/...` refs pointed at the registered document. That keeps recursive schemas valid.
|
|
1271
|
+
*/
|
|
1272
|
+
const VALIDATION_HEADER = "x-swaggerizer-validation";
|
|
1273
|
+
const MAX_ISSUES = 20;
|
|
1274
|
+
const IGNORED_HEADER_PARAMS = new Set(["accept", "content-type", "authorization"]);
|
|
1275
|
+
const PARAM_LOCATIONS = new Set(["path", "query", "header", "cookie"]);
|
|
1276
|
+
const SKIP = Symbol("swaggerizer-skip");
|
|
1277
|
+
/* ------------------------------------------------------------------ schema preparation */
|
|
1278
|
+
/** OpenAPI 3.0 spells nullability as `nullable: true`; JSON Schema wants a `null` type. */
|
|
1279
|
+
function withoutNullable(node) {
|
|
1280
|
+
if (Array.isArray(node))
|
|
1281
|
+
return node.map(withoutNullable);
|
|
1282
|
+
if (!node || typeof node !== "object")
|
|
1283
|
+
return node;
|
|
1284
|
+
const out = {};
|
|
1285
|
+
for (const [key, value] of Object.entries(node))
|
|
1286
|
+
out[key] = withoutNullable(value);
|
|
1287
|
+
if (out.nullable !== true)
|
|
1288
|
+
return out;
|
|
1289
|
+
delete out.nullable;
|
|
1290
|
+
if (typeof out.type === "string")
|
|
1291
|
+
out.type = [out.type, "null"];
|
|
1292
|
+
else if (Array.isArray(out.type)) {
|
|
1293
|
+
if (!out.type.includes("null"))
|
|
1294
|
+
out.type = [...out.type, "null"];
|
|
1295
|
+
}
|
|
1296
|
+
else if (out.$ref || out.allOf || out.oneOf || out.anyOf || out.not) {
|
|
1297
|
+
return { anyOf: [out, { type: "null" }] };
|
|
1298
|
+
}
|
|
1299
|
+
return out;
|
|
1300
|
+
}
|
|
1301
|
+
/** Points a schema's internal refs at the registered document so it can be compiled standalone. */
|
|
1302
|
+
function withBaseRefs(value, base) {
|
|
1303
|
+
if (Array.isArray(value))
|
|
1304
|
+
return value.map(entry => withBaseRefs(entry, base));
|
|
1305
|
+
if (!value || typeof value !== "object")
|
|
1306
|
+
return value;
|
|
1307
|
+
const out = {};
|
|
1308
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1309
|
+
out[key] = key === "$ref" && typeof entry === "string" && entry.startsWith("#") ? `${base}${entry}` : withBaseRefs(entry, base);
|
|
1310
|
+
}
|
|
1311
|
+
return out;
|
|
1312
|
+
}
|
|
1313
|
+
function createAjv(specVersion) {
|
|
1314
|
+
// OAS 3.1 is JSON Schema 2020-12, where `exclusiveMinimum` is a number; draft-07 would reject it.
|
|
1315
|
+
const Ctor = typeof specVersion === "string" && /^3\.[1-9]/.test(specVersion) ? Ajv2020 : Ajv;
|
|
1316
|
+
const ajv = new Ctor({
|
|
1317
|
+
strict: false,
|
|
1318
|
+
allErrors: true,
|
|
1319
|
+
coerceTypes: false,
|
|
1320
|
+
useDefaults: false,
|
|
1321
|
+
allowUnionTypes: true,
|
|
1322
|
+
validateFormats: true
|
|
1323
|
+
});
|
|
1324
|
+
addFormats(ajv);
|
|
1325
|
+
return ajv;
|
|
1326
|
+
}
|
|
1327
|
+
function typeOf(schema) {
|
|
1328
|
+
const type = schema?.type;
|
|
1329
|
+
if (Array.isArray(type))
|
|
1330
|
+
return type.find((entry) => entry !== "null") ?? type[0];
|
|
1331
|
+
if (typeof type === "string")
|
|
1332
|
+
return type;
|
|
1333
|
+
if (schema?.properties || schema?.additionalProperties)
|
|
1334
|
+
return "object";
|
|
1335
|
+
if (schema?.items)
|
|
1336
|
+
return "array";
|
|
1337
|
+
return undefined;
|
|
1338
|
+
}
|
|
1339
|
+
function headerValue(headers, name) {
|
|
1340
|
+
const value = headers[name.toLowerCase()];
|
|
1341
|
+
if (Array.isArray(value))
|
|
1342
|
+
return value.join(", ");
|
|
1343
|
+
return value === undefined ? undefined : String(value);
|
|
1344
|
+
}
|
|
1345
|
+
/* ------------------------------------------------------------------ parameters */
|
|
1346
|
+
/** Delimiter of a non-exploded array: `form` is comma-separated, the others say so in their name. */
|
|
1347
|
+
function arrayDelimiter(style) {
|
|
1348
|
+
if (style === "spaceDelimited")
|
|
1349
|
+
return " ";
|
|
1350
|
+
if (style === "pipeDelimited")
|
|
1351
|
+
return "|";
|
|
1352
|
+
return ",";
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Exploded `form` objects arrive as their own top-level keys (`?a=1&b=2`) and `deepObject` as
|
|
1356
|
+
* `name[key]=…`. Neither is rebuilt here, so such parameters are reported as skipped, not failed.
|
|
1357
|
+
*/
|
|
1358
|
+
function isSpreadObject(param, type) {
|
|
1359
|
+
return (!param.json &&
|
|
1360
|
+
type === "object" &&
|
|
1361
|
+
(param.in === "query" || param.in === "cookie") &&
|
|
1362
|
+
((param.style === "form" && param.explode) || param.style === "deepObject"));
|
|
1363
|
+
}
|
|
1364
|
+
function hasDeepObjectKeys(query, name) {
|
|
1365
|
+
for (const key of query.keys())
|
|
1366
|
+
if (key.startsWith(`${name}[`))
|
|
1367
|
+
return true;
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
/** The value a parameter arrived as: repeated query keys for arrays, the last cookie for scalars. */
|
|
1371
|
+
function rawParam(param, input, type) {
|
|
1372
|
+
if (param.in === "path")
|
|
1373
|
+
return input.params[param.name];
|
|
1374
|
+
if (param.in === "query") {
|
|
1375
|
+
if (type === "array" && !param.json)
|
|
1376
|
+
return input.query.getAll(param.name);
|
|
1377
|
+
return input.query.get(param.name) ?? undefined;
|
|
1378
|
+
}
|
|
1379
|
+
if (param.in === "header")
|
|
1380
|
+
return headerValue(input.headers, param.name);
|
|
1381
|
+
const cookie = headerValue(input.headers, "cookie");
|
|
1382
|
+
if (!cookie)
|
|
1383
|
+
return undefined;
|
|
1384
|
+
const found = [];
|
|
1385
|
+
for (const part of cookie.split(";")) {
|
|
1386
|
+
const at = part.indexOf("=");
|
|
1387
|
+
if (at < 0)
|
|
1388
|
+
continue;
|
|
1389
|
+
if (part.slice(0, at).trim() === param.name)
|
|
1390
|
+
found.push(part.slice(at + 1).trim());
|
|
1391
|
+
}
|
|
1392
|
+
if (found.length === 0)
|
|
1393
|
+
return undefined;
|
|
1394
|
+
return type === "array" ? found : found[found.length - 1];
|
|
1395
|
+
}
|
|
1396
|
+
function coerceScalar(doc, value, schemaIn) {
|
|
1397
|
+
const { node: schema } = deref(doc, schemaIn);
|
|
1398
|
+
const type = typeOf(schema);
|
|
1399
|
+
if (type === "integer" || type === "number") {
|
|
1400
|
+
return /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(value) ? Number(value) : value;
|
|
1401
|
+
}
|
|
1402
|
+
if (type === "boolean")
|
|
1403
|
+
return value === "true" ? true : value === "false" ? false : value;
|
|
1404
|
+
return value;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* HTTP hands us strings; the spec talks in types. Validation runs against a coerced copy, so
|
|
1408
|
+
* handlers keep receiving exactly what they always did.
|
|
1409
|
+
*/
|
|
1410
|
+
function coerceParam(doc, param, raw, summary) {
|
|
1411
|
+
const { node: schema } = deref(doc, param.schema);
|
|
1412
|
+
const type = typeOf(schema);
|
|
1413
|
+
if (type === "array") {
|
|
1414
|
+
// Repeated keys (`ids=1&ids=2`) are already separate; a non-exploded value (`ids=1,2`) is split.
|
|
1415
|
+
const split = (value, delimiter) => value.split(delimiter).map(part => part.trim()).filter(part => part !== "");
|
|
1416
|
+
const values = Array.isArray(raw)
|
|
1417
|
+
? param.explode
|
|
1418
|
+
? raw
|
|
1419
|
+
: raw.flatMap(value => split(value, arrayDelimiter(param.style)))
|
|
1420
|
+
: split(String(raw), ",");
|
|
1421
|
+
return values.map(value => coerceScalar(doc, value, schema?.items));
|
|
1422
|
+
}
|
|
1423
|
+
if (type === "object") {
|
|
1424
|
+
if (Array.isArray(raw))
|
|
1425
|
+
return raw;
|
|
1426
|
+
if (isSpreadObject(param, type)) {
|
|
1427
|
+
summary.skipped ??= "object-style";
|
|
1428
|
+
return SKIP;
|
|
1429
|
+
}
|
|
1430
|
+
const out = {};
|
|
1431
|
+
for (const pair of String(raw).split(",")) {
|
|
1432
|
+
if (pair === "")
|
|
1433
|
+
continue;
|
|
1434
|
+
const at = pair.indexOf(":");
|
|
1435
|
+
if (at < 0) {
|
|
1436
|
+
out[pair.trim()] = true;
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
const key = pair.slice(0, at).trim();
|
|
1440
|
+
out[key] = coerceScalar(doc, pair.slice(at + 1).trim(), schema?.properties?.[key] ?? schema?.additionalProperties);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return out;
|
|
1444
|
+
}
|
|
1445
|
+
return Array.isArray(raw) ? raw[raw.length - 1] : coerceScalar(doc, String(raw), schema);
|
|
1446
|
+
}
|
|
1447
|
+
function coerceForm(doc, body, schemaIn, summary) {
|
|
1448
|
+
const { node: schema } = deref(doc, schemaIn);
|
|
1449
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
1450
|
+
return body;
|
|
1451
|
+
const properties = schema?.properties || {};
|
|
1452
|
+
const out = {};
|
|
1453
|
+
for (const [key, value] of Object.entries(body)) {
|
|
1454
|
+
const property = properties[key];
|
|
1455
|
+
const propertyType = typeOf(deref(doc, property).node);
|
|
1456
|
+
if (propertyType === "object") {
|
|
1457
|
+
// Deep form bodies need a parser this tool does not ship; report the gap instead of a false pass.
|
|
1458
|
+
summary.skipped ??= "form-nesting";
|
|
1459
|
+
out[key] = value;
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (propertyType === "array" && typeof value === "string") {
|
|
1463
|
+
out[key] = value.split(",").map(entry => coerceScalar(doc, entry.trim(), deref(doc, property).node?.items));
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
out[key] = typeof value === "string" ? coerceScalar(doc, value, property) : value;
|
|
1467
|
+
}
|
|
1468
|
+
return out;
|
|
1469
|
+
}
|
|
1470
|
+
/* ------------------------------------------------------------------ bodies */
|
|
1471
|
+
function normaliseContentType(value) {
|
|
1472
|
+
if (!value)
|
|
1473
|
+
return undefined;
|
|
1474
|
+
const type = String(value).split(";")[0].trim().toLowerCase();
|
|
1475
|
+
if (!type)
|
|
1476
|
+
return undefined;
|
|
1477
|
+
return type === "*/*" || type === "*" || type === "application/*" ? "application/json" : type;
|
|
1478
|
+
}
|
|
1479
|
+
/** The declared media type this request should be checked against, if any. */
|
|
1480
|
+
function matchMediaType(declared, actual) {
|
|
1481
|
+
if (declared.length === 0)
|
|
1482
|
+
return undefined;
|
|
1483
|
+
const types = declared.map(key => ({ key, type: key.split(";")[0].trim().toLowerCase() }));
|
|
1484
|
+
const wanted = actual ?? "application/json";
|
|
1485
|
+
const exact = types.find(entry => entry.type === wanted);
|
|
1486
|
+
if (exact)
|
|
1487
|
+
return exact.key;
|
|
1488
|
+
if (wanted.endsWith("+json")) {
|
|
1489
|
+
const json = types.find(entry => entry.type === "application/json");
|
|
1490
|
+
if (json)
|
|
1491
|
+
return json.key;
|
|
1492
|
+
}
|
|
1493
|
+
const wildcard = types.find(entry => entry.type === "*/*" || entry.type === "application/*");
|
|
1494
|
+
return wildcard?.key;
|
|
1495
|
+
}
|
|
1496
|
+
const FORM_TYPE = "application/x-www-form-urlencoded";
|
|
1497
|
+
/**
|
|
1498
|
+
* Bodies the server can parse: JSON (`+json` included) and urlencoded forms. No `Content-Type` means
|
|
1499
|
+
* JSON, as it always has. Multipart, octet-stream, text… are skipped rather than failed to parse.
|
|
1500
|
+
*/
|
|
1501
|
+
function readableMediaType(actual) {
|
|
1502
|
+
if (!actual)
|
|
1503
|
+
return true;
|
|
1504
|
+
return actual === "application/json" || actual.endsWith("+json") || actual === FORM_TYPE;
|
|
1505
|
+
}
|
|
1506
|
+
/** True when the operation (or its path item) declares any parameter or a request body. */
|
|
1507
|
+
function hasRequestSchema(doc, op) {
|
|
1508
|
+
const item = doc?.paths?.[op.route];
|
|
1509
|
+
const node = item?.[op.method];
|
|
1510
|
+
return Boolean((Array.isArray(item?.parameters) && item.parameters.length > 0) ||
|
|
1511
|
+
(Array.isArray(node?.parameters) && node.parameters.length > 0) ||
|
|
1512
|
+
node?.requestBody);
|
|
1513
|
+
}
|
|
1514
|
+
/* ------------------------------------------------------------------ issues */
|
|
1515
|
+
function pointerPath(pointer) {
|
|
1516
|
+
if (typeof pointer !== "string" || pointer === "")
|
|
1517
|
+
return "";
|
|
1518
|
+
return pointer.split("/").filter(Boolean).map(decodePointerSegment).join(".");
|
|
1519
|
+
}
|
|
1520
|
+
function issueFromError(error, prefix) {
|
|
1521
|
+
const path = pointerPath(error?.instancePath);
|
|
1522
|
+
const missing = error?.keyword === "required" ? error?.params?.missingProperty : undefined;
|
|
1523
|
+
const location = [prefix, path, missing].filter(Boolean).join(".");
|
|
1524
|
+
return {
|
|
1525
|
+
location,
|
|
1526
|
+
message: String(error?.message ?? "is invalid"),
|
|
1527
|
+
...(error?.keyword ? { keyword: String(error.keyword) } : {})
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
function add(summary, issue) {
|
|
1531
|
+
if (summary.request.length < MAX_ISSUES)
|
|
1532
|
+
summary.request.push(issue);
|
|
1533
|
+
}
|
|
1534
|
+
function runCheck(validate, value) {
|
|
1535
|
+
if (!validate)
|
|
1536
|
+
return [];
|
|
1537
|
+
return validate(value) ? [] : validate.errors ?? [];
|
|
1538
|
+
}
|
|
1539
|
+
/* ------------------------------------------------------------------ matching helpers */
|
|
1540
|
+
/** `X-Swaggerizer-Validation: off | warn | enforce`; unknown values fall back to the configured mode. */
|
|
1541
|
+
function parseValidationMode(value) {
|
|
1542
|
+
if (typeof value !== "string")
|
|
1543
|
+
return undefined;
|
|
1544
|
+
const mode = value.trim().toLowerCase();
|
|
1545
|
+
return mode === "off" || mode === "warn" || mode === "enforce" ? mode : undefined;
|
|
1546
|
+
}
|
|
1547
|
+
/** True when one of `validateIgnore`'s operationIds or `"METHOD /route"` entries covers `op`. */
|
|
1548
|
+
function matchesIgnore(ignore, op) {
|
|
1549
|
+
if (!ignore || ignore.length === 0)
|
|
1550
|
+
return false;
|
|
1551
|
+
return ignore.some(entry => {
|
|
1552
|
+
const value = String(entry).trim();
|
|
1553
|
+
return value === op.operationId || value === `${op.method.toUpperCase()} ${op.route}` || value === op.key;
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
/** `validateIgnore` entries that match no operation, reported as a spec warning. */
|
|
1557
|
+
function unmatchedIgnore(ignore, operations) {
|
|
1558
|
+
if (!ignore || ignore.length === 0)
|
|
1559
|
+
return [];
|
|
1560
|
+
return ignore.filter(entry => !operations.some(op => entry === op.operationId ||
|
|
1561
|
+
entry === `${op.method.toUpperCase()} ${op.route}` ||
|
|
1562
|
+
entry === op.key));
|
|
1563
|
+
}
|
|
1564
|
+
/** "query.limit: must be integer" — used for log tooltips and CLI output. */
|
|
1565
|
+
function formatIssues(issues) {
|
|
1566
|
+
return issues.map(issue => `${issue.location}: ${issue.message}`).join("; ");
|
|
1567
|
+
}
|
|
1568
|
+
/* ------------------------------------------------------------------ the validator */
|
|
1569
|
+
function createValidator(doc, specId, specVersion) {
|
|
1570
|
+
const base = `urn:swaggerizer:${encodeURIComponent(specId)}`;
|
|
1571
|
+
const ajv = createAjv(specVersion);
|
|
1572
|
+
try {
|
|
1573
|
+
ajv.addSchema(withoutNullable(structuredClone(doc)), base);
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
// A document Ajv refuses to register simply leaves every check skipped, never the mock broken.
|
|
1577
|
+
}
|
|
1578
|
+
const plans = new Map();
|
|
1579
|
+
const bodyChecks = new Map();
|
|
1580
|
+
function compile(schema) {
|
|
1581
|
+
try {
|
|
1582
|
+
// Inline operation schemas get the same `nullable` rewrite as the registered document.
|
|
1583
|
+
return ajv.compile(withBaseRefs(withoutNullable(schema), base));
|
|
1584
|
+
}
|
|
1585
|
+
catch {
|
|
1586
|
+
return undefined;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function buildPlan(op) {
|
|
1590
|
+
const item = doc?.paths?.[op.route];
|
|
1591
|
+
const node = item?.[op.method];
|
|
1592
|
+
const plan = { params: [] };
|
|
1593
|
+
// An operation-level parameter replaces a path-level one with the same name and location.
|
|
1594
|
+
const declared = new Map();
|
|
1595
|
+
for (const entry of [
|
|
1596
|
+
...(Array.isArray(item?.parameters) ? item.parameters : []),
|
|
1597
|
+
...(Array.isArray(node?.parameters) ? node.parameters : [])
|
|
1598
|
+
]) {
|
|
1599
|
+
const { node: param } = deref(doc, entry);
|
|
1600
|
+
if (!param || typeof param !== "object" || !param.name || typeof param.in !== "string")
|
|
1601
|
+
continue;
|
|
1602
|
+
const name = param.in === "header" ? String(param.name).toLowerCase() : String(param.name);
|
|
1603
|
+
declared.set(`${param.in}\u0000${name}`, param);
|
|
1604
|
+
}
|
|
1605
|
+
for (const param of declared.values()) {
|
|
1606
|
+
if (!PARAM_LOCATIONS.has(param.in))
|
|
1607
|
+
continue;
|
|
1608
|
+
const where = param.in;
|
|
1609
|
+
if (where === "header" && IGNORED_HEADER_PARAMS.has(String(param.name).toLowerCase()))
|
|
1610
|
+
continue;
|
|
1611
|
+
let schema = param.schema;
|
|
1612
|
+
let json = false;
|
|
1613
|
+
if (!schema && param.content && typeof param.content === "object") {
|
|
1614
|
+
// Only a JSON `content` parameter can be read; any other media type is left unchecked.
|
|
1615
|
+
const key = Object.keys(param.content).find(entry => /^application\/(.+\+)?json\b/i.test(entry.trim()));
|
|
1616
|
+
if (key) {
|
|
1617
|
+
schema = param.content[key]?.schema;
|
|
1618
|
+
json = true;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (!schema)
|
|
1622
|
+
continue;
|
|
1623
|
+
const style = typeof param.style === "string" ? param.style : where === "query" || where === "cookie" ? "form" : "simple";
|
|
1624
|
+
const compiled = compile(schema);
|
|
1625
|
+
plan.params.push({
|
|
1626
|
+
name: String(param.name),
|
|
1627
|
+
in: where,
|
|
1628
|
+
required: where === "path" || param.required === true,
|
|
1629
|
+
style,
|
|
1630
|
+
explode: typeof param.explode === "boolean" ? param.explode : style === "form",
|
|
1631
|
+
schema,
|
|
1632
|
+
json,
|
|
1633
|
+
...(compiled ? { validate: compiled } : { problem: "invalid-schema" })
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
const requestBody = node?.requestBody ? deref(doc, node.requestBody).node : undefined;
|
|
1637
|
+
if (requestBody && typeof requestBody === "object" && requestBody.content && typeof requestBody.content === "object") {
|
|
1638
|
+
plan.body = { required: requestBody.required === true, content: requestBody.content };
|
|
1639
|
+
}
|
|
1640
|
+
return plan;
|
|
1641
|
+
}
|
|
1642
|
+
function planFor(op) {
|
|
1643
|
+
let plan = plans.get(op.key);
|
|
1644
|
+
if (!plan) {
|
|
1645
|
+
plan = buildPlan(op);
|
|
1646
|
+
plans.set(op.key, plan);
|
|
1647
|
+
}
|
|
1648
|
+
return plan;
|
|
1649
|
+
}
|
|
1650
|
+
function bodyCheck(op, mediaKey, schema) {
|
|
1651
|
+
const key = `${op.key}\u0000${mediaKey}`;
|
|
1652
|
+
if (bodyChecks.has(key))
|
|
1653
|
+
return bodyChecks.get(key);
|
|
1654
|
+
const compiled = compile(schema);
|
|
1655
|
+
bodyChecks.set(key, compiled);
|
|
1656
|
+
return compiled;
|
|
1657
|
+
}
|
|
1658
|
+
function validateRequest(op, input) {
|
|
1659
|
+
const plan = planFor(op);
|
|
1660
|
+
const summary = { request: [] };
|
|
1661
|
+
for (const param of plan.params) {
|
|
1662
|
+
const { node: schema } = deref(doc, param.schema);
|
|
1663
|
+
const type = typeOf(schema);
|
|
1664
|
+
if (isSpreadObject(param, type)) {
|
|
1665
|
+
// `deepObject` keys can at least be seen; exploded form objects cannot be told from other keys.
|
|
1666
|
+
const deep = param.style === "deepObject" && param.in === "query";
|
|
1667
|
+
const present = deep && hasDeepObjectKeys(input.query, param.name);
|
|
1668
|
+
if (deep && !present && param.required) {
|
|
1669
|
+
add(summary, {
|
|
1670
|
+
location: `${param.in}.${param.name}`,
|
|
1671
|
+
message: `must have required parameter '${param.name}'`,
|
|
1672
|
+
keyword: "required"
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
else if (present || param.required) {
|
|
1676
|
+
summary.skipped ??= "object-style";
|
|
1677
|
+
}
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
const raw = rawParam(param, input, type);
|
|
1681
|
+
const missing = raw === undefined || (Array.isArray(raw) ? raw.length === 0 : raw === "");
|
|
1682
|
+
if (missing) {
|
|
1683
|
+
if (param.required) {
|
|
1684
|
+
add(summary, {
|
|
1685
|
+
location: `${param.in}.${param.name}`,
|
|
1686
|
+
message: `must have required parameter '${param.name}'`,
|
|
1687
|
+
keyword: "required"
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
if (param.problem) {
|
|
1693
|
+
summary.skipped ??= param.problem;
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1696
|
+
let value;
|
|
1697
|
+
if (param.json) {
|
|
1698
|
+
const text = Array.isArray(raw) ? raw[raw.length - 1] : String(raw);
|
|
1699
|
+
try {
|
|
1700
|
+
value = JSON.parse(text);
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
add(summary, { location: `${param.in}.${param.name}`, message: "must be valid JSON", keyword: "parse" });
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
value = coerceParam(doc, param, raw, summary);
|
|
1709
|
+
}
|
|
1710
|
+
if (value === SKIP)
|
|
1711
|
+
continue;
|
|
1712
|
+
for (const error of runCheck(param.validate, value)) {
|
|
1713
|
+
add(summary, issueFromError(error, `${param.in}.${param.name}`));
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
if (plan.body) {
|
|
1717
|
+
const actual = normaliseContentType(input.contentType);
|
|
1718
|
+
const mediaKey = matchMediaType(Object.keys(plan.body.content), actual);
|
|
1719
|
+
if (!mediaKey || !readableMediaType(actual)) {
|
|
1720
|
+
summary.skipped ??= "unsupported-media-type";
|
|
1721
|
+
}
|
|
1722
|
+
else if (input.bodyError) {
|
|
1723
|
+
add(summary, { location: "body", message: input.bodyError, keyword: "parse" });
|
|
1724
|
+
}
|
|
1725
|
+
else if (input.body === undefined) {
|
|
1726
|
+
if (plan.body.required) {
|
|
1727
|
+
add(summary, { location: "body", message: "must have a request body", keyword: "required" });
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
else {
|
|
1731
|
+
const schema = plan.body.content[mediaKey]?.schema;
|
|
1732
|
+
const check = schema ? bodyCheck(op, mediaKey, schema) : undefined;
|
|
1733
|
+
if (schema && !check) {
|
|
1734
|
+
summary.skipped ??= "invalid-schema";
|
|
1735
|
+
}
|
|
1736
|
+
else if (check) {
|
|
1737
|
+
const form = (actual ?? mediaKey.split(";")[0].trim().toLowerCase()) === FORM_TYPE;
|
|
1738
|
+
const value = form ? coerceForm(doc, input.body, schema, summary) : input.body;
|
|
1739
|
+
for (const error of runCheck(check, value)) {
|
|
1740
|
+
add(summary, issueFromError(error, "body"));
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
return summary;
|
|
1746
|
+
}
|
|
1747
|
+
return { validateRequest, hasRequestSchema: op => hasRequestSchema(doc, op) };
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1234
1750
|
/* The Swaggerizer mock server: serves every loaded spec and exposes the admin API + page. */
|
|
1235
1751
|
const REQUEST_LOG_LIMIT = 300;
|
|
1236
1752
|
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
@@ -1322,6 +1838,41 @@ function createSwaggerizer(options = {}) {
|
|
|
1322
1838
|
/* ------------------------------------------------------------------ state */
|
|
1323
1839
|
const ordered = () => [...specs.values()];
|
|
1324
1840
|
const states = () => scenario.buildOperationStates(ordered(), scenarios, { defaultDelay: options.delay });
|
|
1841
|
+
/* ------------------------------------------------------------------ request validation */
|
|
1842
|
+
const validators = new Map();
|
|
1843
|
+
function validatorFor(spec) {
|
|
1844
|
+
let validator = validators.get(spec.id);
|
|
1845
|
+
if (!validator) {
|
|
1846
|
+
validator = createValidator(spec.doc, spec.id, spec.specVersion);
|
|
1847
|
+
validators.set(spec.id, validator);
|
|
1848
|
+
}
|
|
1849
|
+
return validator;
|
|
1850
|
+
}
|
|
1851
|
+
/** Header override, then `validateIgnore`, then the spec's mode, then the server's. */
|
|
1852
|
+
function validationModeFor(op, header) {
|
|
1853
|
+
const fromHeader = parseValidationMode(Array.isArray(header) ? header[0] : header);
|
|
1854
|
+
if (fromHeader)
|
|
1855
|
+
return fromHeader;
|
|
1856
|
+
const cfg = configFor(op.specId);
|
|
1857
|
+
if (matchesIgnore(cfg.validateIgnore, op))
|
|
1858
|
+
return "off";
|
|
1859
|
+
return cfg.validate ?? options.specValidation?.[op.specId] ?? options.validate ?? "off";
|
|
1860
|
+
}
|
|
1861
|
+
function validationInfo(operations = states()) {
|
|
1862
|
+
const perSpec = { ...(options.specValidation || {}) };
|
|
1863
|
+
for (const [id, cfg] of Object.entries(specConfigs))
|
|
1864
|
+
if (cfg.validate)
|
|
1865
|
+
perSpec[id] = cfg.validate;
|
|
1866
|
+
return {
|
|
1867
|
+
mode: options.validate ?? "off",
|
|
1868
|
+
perSpec,
|
|
1869
|
+
operations: operations.map(op => {
|
|
1870
|
+
const spec = specs.get(op.specId);
|
|
1871
|
+
// Read straight from the document: reporting this must not build an Ajv instance while validation is off.
|
|
1872
|
+
return { specId: op.specId, key: op.key, hasRequestSchema: spec ? hasRequestSchema(spec.doc, op) : false };
|
|
1873
|
+
})
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1325
1876
|
function summarise(spec) {
|
|
1326
1877
|
return {
|
|
1327
1878
|
id: spec.id,
|
|
@@ -1346,14 +1897,16 @@ function createSwaggerizer(options = {}) {
|
|
|
1346
1897
|
...snapshotReports,
|
|
1347
1898
|
...[...previousReports.values()].filter(r => r.changes.length > 0)
|
|
1348
1899
|
];
|
|
1900
|
+
const operations = states();
|
|
1349
1901
|
return {
|
|
1350
1902
|
endpoint,
|
|
1351
1903
|
...(options.delay ? { defaultDelay: options.delay } : {}),
|
|
1352
1904
|
...(options.fallback ? { fallback: options.fallback } : {}),
|
|
1353
1905
|
specs: ordered().map(summarise),
|
|
1354
|
-
operations
|
|
1906
|
+
operations,
|
|
1355
1907
|
lastReload,
|
|
1356
|
-
changes
|
|
1908
|
+
changes,
|
|
1909
|
+
validation: validationInfo(operations)
|
|
1357
1910
|
};
|
|
1358
1911
|
}
|
|
1359
1912
|
function servingOperation(method, route) {
|
|
@@ -1488,6 +2041,9 @@ function createSwaggerizer(options = {}) {
|
|
|
1488
2041
|
for (const key of config.unmatchedKeys(cfg.handlers, analysed.operations)) {
|
|
1489
2042
|
analysed.warnings.push(`Custom handler '${key}' matches no operation`);
|
|
1490
2043
|
}
|
|
2044
|
+
for (const key of unmatchedIgnore(cfg.validateIgnore, analysed.operations)) {
|
|
2045
|
+
analysed.warnings.push(`validateIgnore entry '${key}' matches no operation`);
|
|
2046
|
+
}
|
|
1491
2047
|
const enabled = before ? before.enabled : !disabled.has(id);
|
|
1492
2048
|
next.set(id, { ...analysed, enabled, loadedAt: new Date().toISOString() });
|
|
1493
2049
|
if (before && !before.error) {
|
|
@@ -1519,6 +2075,8 @@ function createSwaggerizer(options = {}) {
|
|
|
1519
2075
|
}
|
|
1520
2076
|
}
|
|
1521
2077
|
specs.clear();
|
|
2078
|
+
// Compiled validators hold the old documents; rebuild them lazily against the new ones.
|
|
2079
|
+
validators.clear();
|
|
1522
2080
|
for (const [id, spec] of next)
|
|
1523
2081
|
specs.set(id, spec);
|
|
1524
2082
|
for (const id of previousReports.keys())
|
|
@@ -1593,18 +2151,18 @@ function createSwaggerizer(options = {}) {
|
|
|
1593
2151
|
return reload();
|
|
1594
2152
|
}
|
|
1595
2153
|
/* ------------------------------------------------------------------ mock serving */
|
|
1596
|
-
async function proxy(req, res, target, relativePath, search) {
|
|
2154
|
+
async function proxy(req, res, target, relativePath, search, bufferedBody) {
|
|
1597
2155
|
const upstream = target.replace(/\/+$/, "") + relativePath + search;
|
|
1598
2156
|
const headers = {};
|
|
1599
2157
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
1600
2158
|
if (value === undefined)
|
|
1601
2159
|
continue;
|
|
1602
|
-
if (["host", "connection", "content-length", "accept-encoding", scenario.SCENARIO_HEADER].includes(name))
|
|
2160
|
+
if (["host", "connection", "content-length", "accept-encoding", scenario.SCENARIO_HEADER, VALIDATION_HEADER].includes(name))
|
|
1603
2161
|
continue;
|
|
1604
2162
|
headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
1605
2163
|
}
|
|
1606
2164
|
const method = (req.method || "GET").toUpperCase();
|
|
1607
|
-
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req);
|
|
2165
|
+
const body = method === "GET" || method === "HEAD" ? undefined : bufferedBody ?? (await readBody(req));
|
|
1608
2166
|
const response = await fetch(upstream, { method, headers, body: body && body.length ? new Uint8Array(body) : undefined, redirect: "manual" });
|
|
1609
2167
|
const out = Buffer.from(await response.arrayBuffer());
|
|
1610
2168
|
res.statusCode = response.status;
|
|
@@ -1631,22 +2189,79 @@ function createSwaggerizer(options = {}) {
|
|
|
1631
2189
|
const spec = specs.get(op.specId);
|
|
1632
2190
|
const cfg = configFor(op.specId);
|
|
1633
2191
|
const override = scenario.parseScenarioHeader(req.headers[scenario.SCENARIO_HEADER]);
|
|
1634
|
-
const
|
|
2192
|
+
const mode = validationModeFor(op, req.headers[VALIDATION_HEADER]);
|
|
2193
|
+
const resolved = scenario.resolveResponse(op, override);
|
|
1635
2194
|
const custom = config.lookupByOperation(cfg.handlers, op);
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2195
|
+
const wantsHandler = Boolean(custom) && !override && !op.modified;
|
|
2196
|
+
const sendsBody = method !== "get" && method !== "head";
|
|
2197
|
+
// The request stream can only be read once: buffer it when validation, a custom handler or the
|
|
2198
|
+
// proxy needs it, then hand the same buffer to each of them.
|
|
2199
|
+
const buffered = sendsBody && (mode !== "off" || wantsHandler || resolved.kind === "proxy")
|
|
2200
|
+
? await readBody(req)
|
|
2201
|
+
: undefined;
|
|
2202
|
+
const contentType = req.headers["content-type"];
|
|
2203
|
+
// What a custom handler has always received: parsed JSON, or the raw text when it is not JSON.
|
|
2204
|
+
let handlerBody;
|
|
2205
|
+
if (buffered && buffered.length > 0) {
|
|
2206
|
+
const text = buffered.toString("utf8");
|
|
2207
|
+
try {
|
|
2208
|
+
handlerBody = text.trim() ? JSON.parse(text) : undefined;
|
|
2209
|
+
}
|
|
2210
|
+
catch {
|
|
2211
|
+
handlerBody = text;
|
|
1646
2212
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
2213
|
+
}
|
|
2214
|
+
let validationBody;
|
|
2215
|
+
let bodyError;
|
|
2216
|
+
// A whitespace-only body is no body, for validation as for the handler.
|
|
2217
|
+
if (mode !== "off" && buffered && buffered.toString("utf8").trim() !== "") {
|
|
2218
|
+
try {
|
|
2219
|
+
validationBody = parseRequestBody(buffered, contentType);
|
|
2220
|
+
}
|
|
2221
|
+
catch (err) {
|
|
2222
|
+
bodyError = err?.message ? String(err.message) : String(err);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
const pathParams = scenario.candidatePaths(url.pathname, spec.basePaths, cfg.aliases || {})
|
|
2226
|
+
.map(p => config.extractParams(op.route, p))
|
|
2227
|
+
.find(p => Object.keys(p).length > 0) ?? {};
|
|
2228
|
+
let summary;
|
|
2229
|
+
if (mode !== "off") {
|
|
2230
|
+
summary = validatorFor(spec).validateRequest(op, {
|
|
2231
|
+
params: pathParams,
|
|
2232
|
+
query: url.searchParams,
|
|
2233
|
+
headers: req.headers,
|
|
2234
|
+
...(validationBody !== undefined ? { body: validationBody } : {}),
|
|
2235
|
+
...(bodyError ? { bodyError } : {}),
|
|
2236
|
+
...(contentType ? { contentType } : {})
|
|
2237
|
+
});
|
|
2238
|
+
if (summary.request.length === 0 && !summary.skipped)
|
|
2239
|
+
summary = undefined;
|
|
2240
|
+
}
|
|
2241
|
+
const entry = {
|
|
2242
|
+
method: method.toUpperCase(),
|
|
2243
|
+
path: url.pathname + url.search,
|
|
2244
|
+
route: op.route,
|
|
2245
|
+
specId: op.specId,
|
|
2246
|
+
source: "server",
|
|
2247
|
+
...(summary ? { validation: summary } : {})
|
|
2248
|
+
};
|
|
2249
|
+
if (summary && mode === "enforce" && summary.request.length > 0) {
|
|
2250
|
+
res.setHeader("X-Swaggerizer-Validation", "failed");
|
|
2251
|
+
sendJson(res, 400, {
|
|
2252
|
+
error: "Request validation failed",
|
|
2253
|
+
operation: `${op.method.toUpperCase()} ${op.route}`,
|
|
2254
|
+
specId: op.specId,
|
|
2255
|
+
issues: summary.request
|
|
2256
|
+
});
|
|
2257
|
+
recordRequest({ ...entry, status: 400, example: "validation", ms: Date.now() - started });
|
|
2258
|
+
return true;
|
|
2259
|
+
}
|
|
2260
|
+
if (summary)
|
|
2261
|
+
res.setHeader("X-Swaggerizer-Validation", "warning");
|
|
2262
|
+
if (custom && wantsHandler) {
|
|
2263
|
+
const body = handlerBody;
|
|
2264
|
+
const params = pathParams;
|
|
1650
2265
|
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v ?? "")]));
|
|
1651
2266
|
let outcome;
|
|
1652
2267
|
try {
|
|
@@ -1673,7 +2288,6 @@ function createSwaggerizer(options = {}) {
|
|
|
1673
2288
|
return true;
|
|
1674
2289
|
}
|
|
1675
2290
|
}
|
|
1676
|
-
const resolved = scenario.resolveResponse(op, override);
|
|
1677
2291
|
if (resolved.delay > 0)
|
|
1678
2292
|
await sleep(resolved.delay);
|
|
1679
2293
|
if (resolved.kind === "proxy") {
|
|
@@ -1685,7 +2299,7 @@ function createSwaggerizer(options = {}) {
|
|
|
1685
2299
|
}
|
|
1686
2300
|
const relative = scenario.relativeToBase(url.pathname, spec.basePaths, cfg.aliases || {});
|
|
1687
2301
|
try {
|
|
1688
|
-
const status = await proxy(req, res, target, relative, url.search);
|
|
2302
|
+
const status = await proxy(req, res, target, relative, url.search, buffered);
|
|
1689
2303
|
recordRequest({ ...entry, status, example: "proxy", ms: Date.now() - started });
|
|
1690
2304
|
}
|
|
1691
2305
|
catch (err) {
|
|
@@ -1723,14 +2337,15 @@ function createSwaggerizer(options = {}) {
|
|
|
1723
2337
|
reload,
|
|
1724
2338
|
getRequestLog,
|
|
1725
2339
|
recordRequest,
|
|
1726
|
-
getSpecDocument: id => specs.get(id)?.doc
|
|
2340
|
+
getSpecDocument: id => specs.get(id)?.doc,
|
|
2341
|
+
getValidation: validationInfo
|
|
1727
2342
|
});
|
|
1728
2343
|
function handler(req, res, next) {
|
|
1729
2344
|
if (options.cors !== false) {
|
|
1730
2345
|
res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "*");
|
|
1731
2346
|
res.setHeader("Vary", "Origin");
|
|
1732
2347
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
1733
|
-
res.setHeader("Access-Control-Expose-Headers", "X-Swaggerizer-Scenario, X-Swaggerizer-Proxied");
|
|
2348
|
+
res.setHeader("Access-Control-Expose-Headers", "X-Swaggerizer-Scenario, X-Swaggerizer-Proxied, X-Swaggerizer-Validation");
|
|
1734
2349
|
if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
|
|
1735
2350
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS");
|
|
1736
2351
|
res.setHeader("Access-Control-Allow-Headers", req.headers["access-control-request-headers"] || "*");
|
|
@@ -1824,6 +2439,7 @@ function createSwaggerizer(options = {}) {
|
|
|
1824
2439
|
getRequestLog,
|
|
1825
2440
|
recordRequest,
|
|
1826
2441
|
getSpecDocument: id => specs.get(id)?.doc,
|
|
2442
|
+
getValidationInfo: validationInfo,
|
|
1827
2443
|
listen,
|
|
1828
2444
|
close,
|
|
1829
2445
|
endpoint,
|
|
@@ -2364,6 +2980,7 @@ exports.lookupByOperation = config.lookupByOperation;
|
|
|
2364
2980
|
exports.result = config.result;
|
|
2365
2981
|
exports.unmatchedKeys = config.unmatchedKeys;
|
|
2366
2982
|
exports.CONTRACT_FILE = CONTRACT_FILE;
|
|
2983
|
+
exports.VALIDATION_HEADER = VALIDATION_HEADER;
|
|
2367
2984
|
exports.WRAPPER_FILE = WRAPPER_FILE;
|
|
2368
2985
|
exports.aliasesFromTsconfig = aliasesFromTsconfig;
|
|
2369
2986
|
exports.analyseSpecFile = analyseSpecFile;
|
|
@@ -2373,19 +2990,23 @@ exports.buildContract = buildContract;
|
|
|
2373
2990
|
exports.checkChanges = checkChanges;
|
|
2374
2991
|
exports.contractKey = contractKey;
|
|
2375
2992
|
exports.createSwaggerizer = createSwaggerizer;
|
|
2993
|
+
exports.createValidator = createValidator;
|
|
2376
2994
|
exports.diffContracts = diffContracts;
|
|
2377
2995
|
exports.exceedsThreshold = exceedsThreshold;
|
|
2378
2996
|
exports.findConfigFile = findConfigFile;
|
|
2379
2997
|
exports.findMockAggregate = findMockAggregate;
|
|
2998
|
+
exports.formatIssues = formatIssues;
|
|
2380
2999
|
exports.formatReport = formatReport;
|
|
2381
3000
|
exports.generate = generate;
|
|
2382
3001
|
exports.hasChanges = hasChanges;
|
|
2383
3002
|
exports.listSpecFiles = listSpecFiles;
|
|
2384
3003
|
exports.loadConfig = loadConfig;
|
|
2385
3004
|
exports.loadSpecFile = loadSpecFile;
|
|
3005
|
+
exports.matchesIgnore = matchesIgnore;
|
|
2386
3006
|
exports.moveMocks = moveMocks;
|
|
2387
3007
|
exports.normaliseMediaType = normaliseMediaType;
|
|
2388
3008
|
exports.outputDirs = outputDirs;
|
|
3009
|
+
exports.parseValidationMode = parseValidationMode;
|
|
2389
3010
|
exports.pull = pull;
|
|
2390
3011
|
exports.resolveSpecInputs = resolveSpecInputs;
|
|
2391
3012
|
exports.resolveTargets = resolveTargets;
|