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