swaggerizer 1.0.1 → 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/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 => ctx.recordRequest({
214
- method: String(item?.method || "GET").toUpperCase(),
215
- path: String(item?.path || ""),
216
- ...(item?.route ? { route: String(item.route) } : {}),
217
- status: Number(item?.status) || 0,
218
- example: String(item?.example || ""),
219
- ...(item?.specId ? { specId: String(item.specId) } : {}),
220
- ...(item?.ms !== undefined ? { ms: Number(item.ms) } : {}),
221
- source: "msw"
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: states(),
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 entry = { method: method.toUpperCase(), path: url.pathname + url.search, route: op.route, specId: op.specId, source: "server" };
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
- if (custom && !override && !op.modified) {
1637
- const raw = method === "get" || method === "head" ? undefined : (await readBody(req)).toString("utf8");
1638
- let body = raw;
1639
- if (raw !== undefined) {
1640
- try {
1641
- body = raw.trim() ? JSON.parse(raw) : undefined;
1642
- }
1643
- catch {
1644
- body = raw;
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
- const params = scenario.candidatePaths(url.pathname, spec.basePaths, cfg.aliases || {})
1648
- .map(p => config.extractParams(op.route, p))
1649
- .find(p => Object.keys(p).length > 0) ?? {};
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;