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/cli.js CHANGED
@@ -11,6 +11,9 @@ var os = require('node:os');
11
11
  var SwaggerParser = require('@apidevtools/swagger-parser');
12
12
  var http = require('node:http');
13
13
  var node_url = require('node:url');
14
+ var Ajv = require('ajv');
15
+ var Ajv2020 = require('ajv/dist/2020');
16
+ var addFormats = require('ajv-formats');
14
17
 
15
18
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
16
19
  /*
@@ -1857,6 +1860,23 @@ function sendJson(res, status, payload) {
1857
1860
  function blank(value) {
1858
1861
  return value === undefined || value === null || value === "";
1859
1862
  }
1863
+ /** MSW reports request summaries to the live log; keep only the fields the page renders. */
1864
+ function toValidation(raw) {
1865
+ if (!raw || typeof raw !== "object")
1866
+ return undefined;
1867
+ const text = (value, max = 200) => typeof value === "string" && value !== "" ? value.slice(0, max) : undefined;
1868
+ const request = (Array.isArray(raw.request) ? raw.request : [])
1869
+ .slice(0, 20)
1870
+ .map((issue) => ({
1871
+ location: text(issue?.location, 120) ?? "request",
1872
+ message: text(issue?.message) ?? "is invalid",
1873
+ ...(text(issue?.keyword, 40) ? { keyword: text(issue?.keyword, 40) } : {})
1874
+ }));
1875
+ const skipped = text(raw.skipped, 60);
1876
+ if (request.length === 0 && !skipped)
1877
+ return undefined;
1878
+ return { request, ...(skipped ? { skipped } : {}) };
1879
+ }
1860
1880
  /**
1861
1881
  * Turns a flat record (query string, form post or JSON) into a selection. Accepts the ramlizer
1862
1882
  * names (`nextResponseCode`, `nextExampleName`) and flat custom fields (`status`, `body`, `contentType`).
@@ -1951,6 +1971,10 @@ function registerAdmin(ctx) {
1951
1971
  sendJson(res, 200, { changes: ctx.getState().changes });
1952
1972
  return true;
1953
1973
  }
1974
+ if (method === "GET" && path === "/api/validation") {
1975
+ sendJson(res, 200, ctx.getValidation());
1976
+ return true;
1977
+ }
1954
1978
  if (path === "/api/log") {
1955
1979
  if (method === "GET") {
1956
1980
  const since = Number(query.get("since") || 0);
@@ -1960,16 +1984,20 @@ function registerAdmin(ctx) {
1960
1984
  if (method === "POST") {
1961
1985
  // Lets the MSW runtime report requests it answered in the browser or in tests.
1962
1986
  const items = Array.isArray(req.body) ? req.body : [req.body];
1963
- const entries = items.slice(0, 50).map(item => ctx.recordRequest({
1964
- method: String(item?.method || "GET").toUpperCase(),
1965
- path: String(item?.path || ""),
1966
- ...(item?.route ? { route: String(item.route) } : {}),
1967
- status: Number(item?.status) || 0,
1968
- example: String(item?.example || ""),
1969
- ...(item?.specId ? { specId: String(item.specId) } : {}),
1970
- ...(item?.ms !== undefined ? { ms: Number(item.ms) } : {}),
1971
- source: "msw"
1972
- }));
1987
+ const entries = items.slice(0, 50).map(item => {
1988
+ const validation = toValidation(item?.validation);
1989
+ return ctx.recordRequest({
1990
+ method: String(item?.method || "GET").toUpperCase(),
1991
+ path: String(item?.path || ""),
1992
+ ...(item?.route ? { route: String(item.route) } : {}),
1993
+ status: Number(item?.status) || 0,
1994
+ example: String(item?.example || ""),
1995
+ ...(item?.specId ? { specId: String(item.specId) } : {}),
1996
+ ...(item?.ms !== undefined ? { ms: Number(item.ms) } : {}),
1997
+ ...(validation ? { validation } : {}),
1998
+ source: "msw"
1999
+ });
2000
+ });
1973
2001
  sendJson(res, 200, { recorded: entries.length });
1974
2002
  return true;
1975
2003
  }
@@ -2003,6 +2031,490 @@ function registerAdmin(ctx) {
2003
2031
  return { handle, uiAvailable: ctx.ui && assets.available };
2004
2032
  }
2005
2033
 
2034
+ /*
2035
+ * Request validation: checks a matched request against the operation's declared parameters and
2036
+ * request body, so a mock cannot answer `200` to a request the contract forbids.
2037
+ *
2038
+ * Node only. Ajv must never reach the browser entry points (`msw`, `debug-menu`, `config`), which is
2039
+ * why the shared types live in `./types` and this module is not re-exported from them.
2040
+ *
2041
+ * Specs are bundled but not dereferenced: every operation schema is compiled on its own, with its
2042
+ * internal `#/...` refs pointed at the registered document. That keeps recursive schemas valid.
2043
+ */
2044
+ const VALIDATION_HEADER = "x-swaggerizer-validation";
2045
+ const MAX_ISSUES = 20;
2046
+ const IGNORED_HEADER_PARAMS = new Set(["accept", "content-type", "authorization"]);
2047
+ const PARAM_LOCATIONS = new Set(["path", "query", "header", "cookie"]);
2048
+ const SKIP = Symbol("swaggerizer-skip");
2049
+ /* ------------------------------------------------------------------ schema preparation */
2050
+ /** OpenAPI 3.0 spells nullability as `nullable: true`; JSON Schema wants a `null` type. */
2051
+ function withoutNullable(node) {
2052
+ if (Array.isArray(node))
2053
+ return node.map(withoutNullable);
2054
+ if (!node || typeof node !== "object")
2055
+ return node;
2056
+ const out = {};
2057
+ for (const [key, value] of Object.entries(node))
2058
+ out[key] = withoutNullable(value);
2059
+ if (out.nullable !== true)
2060
+ return out;
2061
+ delete out.nullable;
2062
+ if (typeof out.type === "string")
2063
+ out.type = [out.type, "null"];
2064
+ else if (Array.isArray(out.type)) {
2065
+ if (!out.type.includes("null"))
2066
+ out.type = [...out.type, "null"];
2067
+ }
2068
+ else if (out.$ref || out.allOf || out.oneOf || out.anyOf || out.not) {
2069
+ return { anyOf: [out, { type: "null" }] };
2070
+ }
2071
+ return out;
2072
+ }
2073
+ /** Points a schema's internal refs at the registered document so it can be compiled standalone. */
2074
+ function withBaseRefs(value, base) {
2075
+ if (Array.isArray(value))
2076
+ return value.map(entry => withBaseRefs(entry, base));
2077
+ if (!value || typeof value !== "object")
2078
+ return value;
2079
+ const out = {};
2080
+ for (const [key, entry] of Object.entries(value)) {
2081
+ out[key] = key === "$ref" && typeof entry === "string" && entry.startsWith("#") ? `${base}${entry}` : withBaseRefs(entry, base);
2082
+ }
2083
+ return out;
2084
+ }
2085
+ function createAjv(specVersion) {
2086
+ // OAS 3.1 is JSON Schema 2020-12, where `exclusiveMinimum` is a number; draft-07 would reject it.
2087
+ const Ctor = typeof specVersion === "string" && /^3\.[1-9]/.test(specVersion) ? Ajv2020 : Ajv;
2088
+ const ajv = new Ctor({
2089
+ strict: false,
2090
+ allErrors: true,
2091
+ coerceTypes: false,
2092
+ useDefaults: false,
2093
+ allowUnionTypes: true,
2094
+ validateFormats: true
2095
+ });
2096
+ addFormats(ajv);
2097
+ return ajv;
2098
+ }
2099
+ function typeOf(schema) {
2100
+ const type = schema?.type;
2101
+ if (Array.isArray(type))
2102
+ return type.find((entry) => entry !== "null") ?? type[0];
2103
+ if (typeof type === "string")
2104
+ return type;
2105
+ if (schema?.properties || schema?.additionalProperties)
2106
+ return "object";
2107
+ if (schema?.items)
2108
+ return "array";
2109
+ return undefined;
2110
+ }
2111
+ function headerValue(headers, name) {
2112
+ const value = headers[name.toLowerCase()];
2113
+ if (Array.isArray(value))
2114
+ return value.join(", ");
2115
+ return value === undefined ? undefined : String(value);
2116
+ }
2117
+ /* ------------------------------------------------------------------ parameters */
2118
+ /** Delimiter of a non-exploded array: `form` is comma-separated, the others say so in their name. */
2119
+ function arrayDelimiter(style) {
2120
+ if (style === "spaceDelimited")
2121
+ return " ";
2122
+ if (style === "pipeDelimited")
2123
+ return "|";
2124
+ return ",";
2125
+ }
2126
+ /**
2127
+ * Exploded `form` objects arrive as their own top-level keys (`?a=1&b=2`) and `deepObject` as
2128
+ * `name[key]=…`. Neither is rebuilt here, so such parameters are reported as skipped, not failed.
2129
+ */
2130
+ function isSpreadObject(param, type) {
2131
+ return (!param.json &&
2132
+ type === "object" &&
2133
+ (param.in === "query" || param.in === "cookie") &&
2134
+ ((param.style === "form" && param.explode) || param.style === "deepObject"));
2135
+ }
2136
+ function hasDeepObjectKeys(query, name) {
2137
+ for (const key of query.keys())
2138
+ if (key.startsWith(`${name}[`))
2139
+ return true;
2140
+ return false;
2141
+ }
2142
+ /** The value a parameter arrived as: repeated query keys for arrays, the last cookie for scalars. */
2143
+ function rawParam(param, input, type) {
2144
+ if (param.in === "path")
2145
+ return input.params[param.name];
2146
+ if (param.in === "query") {
2147
+ if (type === "array" && !param.json)
2148
+ return input.query.getAll(param.name);
2149
+ return input.query.get(param.name) ?? undefined;
2150
+ }
2151
+ if (param.in === "header")
2152
+ return headerValue(input.headers, param.name);
2153
+ const cookie = headerValue(input.headers, "cookie");
2154
+ if (!cookie)
2155
+ return undefined;
2156
+ const found = [];
2157
+ for (const part of cookie.split(";")) {
2158
+ const at = part.indexOf("=");
2159
+ if (at < 0)
2160
+ continue;
2161
+ if (part.slice(0, at).trim() === param.name)
2162
+ found.push(part.slice(at + 1).trim());
2163
+ }
2164
+ if (found.length === 0)
2165
+ return undefined;
2166
+ return type === "array" ? found : found[found.length - 1];
2167
+ }
2168
+ function coerceScalar(doc, value, schemaIn) {
2169
+ const { node: schema } = deref(doc, schemaIn);
2170
+ const type = typeOf(schema);
2171
+ if (type === "integer" || type === "number") {
2172
+ return /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(value) ? Number(value) : value;
2173
+ }
2174
+ if (type === "boolean")
2175
+ return value === "true" ? true : value === "false" ? false : value;
2176
+ return value;
2177
+ }
2178
+ /**
2179
+ * HTTP hands us strings; the spec talks in types. Validation runs against a coerced copy, so
2180
+ * handlers keep receiving exactly what they always did.
2181
+ */
2182
+ function coerceParam(doc, param, raw, summary) {
2183
+ const { node: schema } = deref(doc, param.schema);
2184
+ const type = typeOf(schema);
2185
+ if (type === "array") {
2186
+ // Repeated keys (`ids=1&ids=2`) are already separate; a non-exploded value (`ids=1,2`) is split.
2187
+ const split = (value, delimiter) => value.split(delimiter).map(part => part.trim()).filter(part => part !== "");
2188
+ const values = Array.isArray(raw)
2189
+ ? param.explode
2190
+ ? raw
2191
+ : raw.flatMap(value => split(value, arrayDelimiter(param.style)))
2192
+ : split(String(raw), ",");
2193
+ return values.map(value => coerceScalar(doc, value, schema?.items));
2194
+ }
2195
+ if (type === "object") {
2196
+ if (Array.isArray(raw))
2197
+ return raw;
2198
+ if (isSpreadObject(param, type)) {
2199
+ summary.skipped ??= "object-style";
2200
+ return SKIP;
2201
+ }
2202
+ const out = {};
2203
+ for (const pair of String(raw).split(",")) {
2204
+ if (pair === "")
2205
+ continue;
2206
+ const at = pair.indexOf(":");
2207
+ if (at < 0) {
2208
+ out[pair.trim()] = true;
2209
+ }
2210
+ else {
2211
+ const key = pair.slice(0, at).trim();
2212
+ out[key] = coerceScalar(doc, pair.slice(at + 1).trim(), schema?.properties?.[key] ?? schema?.additionalProperties);
2213
+ }
2214
+ }
2215
+ return out;
2216
+ }
2217
+ return Array.isArray(raw) ? raw[raw.length - 1] : coerceScalar(doc, String(raw), schema);
2218
+ }
2219
+ function coerceForm(doc, body, schemaIn, summary) {
2220
+ const { node: schema } = deref(doc, schemaIn);
2221
+ if (!body || typeof body !== "object" || Array.isArray(body))
2222
+ return body;
2223
+ const properties = schema?.properties || {};
2224
+ const out = {};
2225
+ for (const [key, value] of Object.entries(body)) {
2226
+ const property = properties[key];
2227
+ const propertyType = typeOf(deref(doc, property).node);
2228
+ if (propertyType === "object") {
2229
+ // Deep form bodies need a parser this tool does not ship; report the gap instead of a false pass.
2230
+ summary.skipped ??= "form-nesting";
2231
+ out[key] = value;
2232
+ continue;
2233
+ }
2234
+ if (propertyType === "array" && typeof value === "string") {
2235
+ out[key] = value.split(",").map(entry => coerceScalar(doc, entry.trim(), deref(doc, property).node?.items));
2236
+ continue;
2237
+ }
2238
+ out[key] = typeof value === "string" ? coerceScalar(doc, value, property) : value;
2239
+ }
2240
+ return out;
2241
+ }
2242
+ /* ------------------------------------------------------------------ bodies */
2243
+ function normaliseContentType(value) {
2244
+ if (!value)
2245
+ return undefined;
2246
+ const type = String(value).split(";")[0].trim().toLowerCase();
2247
+ if (!type)
2248
+ return undefined;
2249
+ return type === "*/*" || type === "*" || type === "application/*" ? "application/json" : type;
2250
+ }
2251
+ /** The declared media type this request should be checked against, if any. */
2252
+ function matchMediaType(declared, actual) {
2253
+ if (declared.length === 0)
2254
+ return undefined;
2255
+ const types = declared.map(key => ({ key, type: key.split(";")[0].trim().toLowerCase() }));
2256
+ const wanted = actual ?? "application/json";
2257
+ const exact = types.find(entry => entry.type === wanted);
2258
+ if (exact)
2259
+ return exact.key;
2260
+ if (wanted.endsWith("+json")) {
2261
+ const json = types.find(entry => entry.type === "application/json");
2262
+ if (json)
2263
+ return json.key;
2264
+ }
2265
+ const wildcard = types.find(entry => entry.type === "*/*" || entry.type === "application/*");
2266
+ return wildcard?.key;
2267
+ }
2268
+ const FORM_TYPE = "application/x-www-form-urlencoded";
2269
+ /**
2270
+ * Bodies the server can parse: JSON (`+json` included) and urlencoded forms. No `Content-Type` means
2271
+ * JSON, as it always has. Multipart, octet-stream, text… are skipped rather than failed to parse.
2272
+ */
2273
+ function readableMediaType(actual) {
2274
+ if (!actual)
2275
+ return true;
2276
+ return actual === "application/json" || actual.endsWith("+json") || actual === FORM_TYPE;
2277
+ }
2278
+ /** True when the operation (or its path item) declares any parameter or a request body. */
2279
+ function hasRequestSchema(doc, op) {
2280
+ const item = doc?.paths?.[op.route];
2281
+ const node = item?.[op.method];
2282
+ return Boolean((Array.isArray(item?.parameters) && item.parameters.length > 0) ||
2283
+ (Array.isArray(node?.parameters) && node.parameters.length > 0) ||
2284
+ node?.requestBody);
2285
+ }
2286
+ /* ------------------------------------------------------------------ issues */
2287
+ function pointerPath(pointer) {
2288
+ if (typeof pointer !== "string" || pointer === "")
2289
+ return "";
2290
+ return pointer.split("/").filter(Boolean).map(decodePointerSegment).join(".");
2291
+ }
2292
+ function issueFromError(error, prefix) {
2293
+ const path = pointerPath(error?.instancePath);
2294
+ const missing = error?.keyword === "required" ? error?.params?.missingProperty : undefined;
2295
+ const location = [prefix, path, missing].filter(Boolean).join(".");
2296
+ return {
2297
+ location,
2298
+ message: String(error?.message ?? "is invalid"),
2299
+ ...(error?.keyword ? { keyword: String(error.keyword) } : {})
2300
+ };
2301
+ }
2302
+ function add(summary, issue) {
2303
+ if (summary.request.length < MAX_ISSUES)
2304
+ summary.request.push(issue);
2305
+ }
2306
+ function runCheck(validate, value) {
2307
+ if (!validate)
2308
+ return [];
2309
+ return validate(value) ? [] : validate.errors ?? [];
2310
+ }
2311
+ /* ------------------------------------------------------------------ matching helpers */
2312
+ /** `X-Swaggerizer-Validation: off | warn | enforce`; unknown values fall back to the configured mode. */
2313
+ function parseValidationMode(value) {
2314
+ if (typeof value !== "string")
2315
+ return undefined;
2316
+ const mode = value.trim().toLowerCase();
2317
+ return mode === "off" || mode === "warn" || mode === "enforce" ? mode : undefined;
2318
+ }
2319
+ /** True when one of `validateIgnore`'s operationIds or `"METHOD /route"` entries covers `op`. */
2320
+ function matchesIgnore(ignore, op) {
2321
+ if (!ignore || ignore.length === 0)
2322
+ return false;
2323
+ return ignore.some(entry => {
2324
+ const value = String(entry).trim();
2325
+ return value === op.operationId || value === `${op.method.toUpperCase()} ${op.route}` || value === op.key;
2326
+ });
2327
+ }
2328
+ /** `validateIgnore` entries that match no operation, reported as a spec warning. */
2329
+ function unmatchedIgnore(ignore, operations) {
2330
+ if (!ignore || ignore.length === 0)
2331
+ return [];
2332
+ return ignore.filter(entry => !operations.some(op => entry === op.operationId ||
2333
+ entry === `${op.method.toUpperCase()} ${op.route}` ||
2334
+ entry === op.key));
2335
+ }
2336
+ /* ------------------------------------------------------------------ the validator */
2337
+ function createValidator(doc, specId, specVersion) {
2338
+ const base = `urn:swaggerizer:${encodeURIComponent(specId)}`;
2339
+ const ajv = createAjv(specVersion);
2340
+ try {
2341
+ ajv.addSchema(withoutNullable(structuredClone(doc)), base);
2342
+ }
2343
+ catch {
2344
+ // A document Ajv refuses to register simply leaves every check skipped, never the mock broken.
2345
+ }
2346
+ const plans = new Map();
2347
+ const bodyChecks = new Map();
2348
+ function compile(schema) {
2349
+ try {
2350
+ // Inline operation schemas get the same `nullable` rewrite as the registered document.
2351
+ return ajv.compile(withBaseRefs(withoutNullable(schema), base));
2352
+ }
2353
+ catch {
2354
+ return undefined;
2355
+ }
2356
+ }
2357
+ function buildPlan(op) {
2358
+ const item = doc?.paths?.[op.route];
2359
+ const node = item?.[op.method];
2360
+ const plan = { params: [] };
2361
+ // An operation-level parameter replaces a path-level one with the same name and location.
2362
+ const declared = new Map();
2363
+ for (const entry of [
2364
+ ...(Array.isArray(item?.parameters) ? item.parameters : []),
2365
+ ...(Array.isArray(node?.parameters) ? node.parameters : [])
2366
+ ]) {
2367
+ const { node: param } = deref(doc, entry);
2368
+ if (!param || typeof param !== "object" || !param.name || typeof param.in !== "string")
2369
+ continue;
2370
+ const name = param.in === "header" ? String(param.name).toLowerCase() : String(param.name);
2371
+ declared.set(`${param.in}\u0000${name}`, param);
2372
+ }
2373
+ for (const param of declared.values()) {
2374
+ if (!PARAM_LOCATIONS.has(param.in))
2375
+ continue;
2376
+ const where = param.in;
2377
+ if (where === "header" && IGNORED_HEADER_PARAMS.has(String(param.name).toLowerCase()))
2378
+ continue;
2379
+ let schema = param.schema;
2380
+ let json = false;
2381
+ if (!schema && param.content && typeof param.content === "object") {
2382
+ // Only a JSON `content` parameter can be read; any other media type is left unchecked.
2383
+ const key = Object.keys(param.content).find(entry => /^application\/(.+\+)?json\b/i.test(entry.trim()));
2384
+ if (key) {
2385
+ schema = param.content[key]?.schema;
2386
+ json = true;
2387
+ }
2388
+ }
2389
+ if (!schema)
2390
+ continue;
2391
+ const style = typeof param.style === "string" ? param.style : where === "query" || where === "cookie" ? "form" : "simple";
2392
+ const compiled = compile(schema);
2393
+ plan.params.push({
2394
+ name: String(param.name),
2395
+ in: where,
2396
+ required: where === "path" || param.required === true,
2397
+ style,
2398
+ explode: typeof param.explode === "boolean" ? param.explode : style === "form",
2399
+ schema,
2400
+ json,
2401
+ ...(compiled ? { validate: compiled } : { problem: "invalid-schema" })
2402
+ });
2403
+ }
2404
+ const requestBody = node?.requestBody ? deref(doc, node.requestBody).node : undefined;
2405
+ if (requestBody && typeof requestBody === "object" && requestBody.content && typeof requestBody.content === "object") {
2406
+ plan.body = { required: requestBody.required === true, content: requestBody.content };
2407
+ }
2408
+ return plan;
2409
+ }
2410
+ function planFor(op) {
2411
+ let plan = plans.get(op.key);
2412
+ if (!plan) {
2413
+ plan = buildPlan(op);
2414
+ plans.set(op.key, plan);
2415
+ }
2416
+ return plan;
2417
+ }
2418
+ function bodyCheck(op, mediaKey, schema) {
2419
+ const key = `${op.key}\u0000${mediaKey}`;
2420
+ if (bodyChecks.has(key))
2421
+ return bodyChecks.get(key);
2422
+ const compiled = compile(schema);
2423
+ bodyChecks.set(key, compiled);
2424
+ return compiled;
2425
+ }
2426
+ function validateRequest(op, input) {
2427
+ const plan = planFor(op);
2428
+ const summary = { request: [] };
2429
+ for (const param of plan.params) {
2430
+ const { node: schema } = deref(doc, param.schema);
2431
+ const type = typeOf(schema);
2432
+ if (isSpreadObject(param, type)) {
2433
+ // `deepObject` keys can at least be seen; exploded form objects cannot be told from other keys.
2434
+ const deep = param.style === "deepObject" && param.in === "query";
2435
+ const present = deep && hasDeepObjectKeys(input.query, param.name);
2436
+ if (deep && !present && param.required) {
2437
+ add(summary, {
2438
+ location: `${param.in}.${param.name}`,
2439
+ message: `must have required parameter '${param.name}'`,
2440
+ keyword: "required"
2441
+ });
2442
+ }
2443
+ else if (present || param.required) {
2444
+ summary.skipped ??= "object-style";
2445
+ }
2446
+ continue;
2447
+ }
2448
+ const raw = rawParam(param, input, type);
2449
+ const missing = raw === undefined || (Array.isArray(raw) ? raw.length === 0 : raw === "");
2450
+ if (missing) {
2451
+ if (param.required) {
2452
+ add(summary, {
2453
+ location: `${param.in}.${param.name}`,
2454
+ message: `must have required parameter '${param.name}'`,
2455
+ keyword: "required"
2456
+ });
2457
+ }
2458
+ continue;
2459
+ }
2460
+ if (param.problem) {
2461
+ summary.skipped ??= param.problem;
2462
+ continue;
2463
+ }
2464
+ let value;
2465
+ if (param.json) {
2466
+ const text = Array.isArray(raw) ? raw[raw.length - 1] : String(raw);
2467
+ try {
2468
+ value = JSON.parse(text);
2469
+ }
2470
+ catch {
2471
+ add(summary, { location: `${param.in}.${param.name}`, message: "must be valid JSON", keyword: "parse" });
2472
+ continue;
2473
+ }
2474
+ }
2475
+ else {
2476
+ value = coerceParam(doc, param, raw, summary);
2477
+ }
2478
+ if (value === SKIP)
2479
+ continue;
2480
+ for (const error of runCheck(param.validate, value)) {
2481
+ add(summary, issueFromError(error, `${param.in}.${param.name}`));
2482
+ }
2483
+ }
2484
+ if (plan.body) {
2485
+ const actual = normaliseContentType(input.contentType);
2486
+ const mediaKey = matchMediaType(Object.keys(plan.body.content), actual);
2487
+ if (!mediaKey || !readableMediaType(actual)) {
2488
+ summary.skipped ??= "unsupported-media-type";
2489
+ }
2490
+ else if (input.bodyError) {
2491
+ add(summary, { location: "body", message: input.bodyError, keyword: "parse" });
2492
+ }
2493
+ else if (input.body === undefined) {
2494
+ if (plan.body.required) {
2495
+ add(summary, { location: "body", message: "must have a request body", keyword: "required" });
2496
+ }
2497
+ }
2498
+ else {
2499
+ const schema = plan.body.content[mediaKey]?.schema;
2500
+ const check = schema ? bodyCheck(op, mediaKey, schema) : undefined;
2501
+ if (schema && !check) {
2502
+ summary.skipped ??= "invalid-schema";
2503
+ }
2504
+ else if (check) {
2505
+ const form = (actual ?? mediaKey.split(";")[0].trim().toLowerCase()) === FORM_TYPE;
2506
+ const value = form ? coerceForm(doc, input.body, schema, summary) : input.body;
2507
+ for (const error of runCheck(check, value)) {
2508
+ add(summary, issueFromError(error, "body"));
2509
+ }
2510
+ }
2511
+ }
2512
+ }
2513
+ return summary;
2514
+ }
2515
+ return { validateRequest, hasRequestSchema: op => hasRequestSchema(doc, op) };
2516
+ }
2517
+
2006
2518
  /* The Swaggerizer mock server: serves every loaded spec and exposes the admin API + page. */
2007
2519
  const REQUEST_LOG_LIMIT = 300;
2008
2520
  const MAX_BODY_BYTES = 5 * 1024 * 1024;
@@ -2094,6 +2606,41 @@ function createSwaggerizer(options = {}) {
2094
2606
  /* ------------------------------------------------------------------ state */
2095
2607
  const ordered = () => [...specs.values()];
2096
2608
  const states = () => buildOperationStates(ordered(), scenarios, { defaultDelay: options.delay });
2609
+ /* ------------------------------------------------------------------ request validation */
2610
+ const validators = new Map();
2611
+ function validatorFor(spec) {
2612
+ let validator = validators.get(spec.id);
2613
+ if (!validator) {
2614
+ validator = createValidator(spec.doc, spec.id, spec.specVersion);
2615
+ validators.set(spec.id, validator);
2616
+ }
2617
+ return validator;
2618
+ }
2619
+ /** Header override, then `validateIgnore`, then the spec's mode, then the server's. */
2620
+ function validationModeFor(op, header) {
2621
+ const fromHeader = parseValidationMode(Array.isArray(header) ? header[0] : header);
2622
+ if (fromHeader)
2623
+ return fromHeader;
2624
+ const cfg = configFor(op.specId);
2625
+ if (matchesIgnore(cfg.validateIgnore, op))
2626
+ return "off";
2627
+ return cfg.validate ?? options.specValidation?.[op.specId] ?? options.validate ?? "off";
2628
+ }
2629
+ function validationInfo(operations = states()) {
2630
+ const perSpec = { ...(options.specValidation || {}) };
2631
+ for (const [id, cfg] of Object.entries(specConfigs))
2632
+ if (cfg.validate)
2633
+ perSpec[id] = cfg.validate;
2634
+ return {
2635
+ mode: options.validate ?? "off",
2636
+ perSpec,
2637
+ operations: operations.map(op => {
2638
+ const spec = specs.get(op.specId);
2639
+ // Read straight from the document: reporting this must not build an Ajv instance while validation is off.
2640
+ return { specId: op.specId, key: op.key, hasRequestSchema: spec ? hasRequestSchema(spec.doc, op) : false };
2641
+ })
2642
+ };
2643
+ }
2097
2644
  function summarise(spec) {
2098
2645
  return {
2099
2646
  id: spec.id,
@@ -2118,14 +2665,16 @@ function createSwaggerizer(options = {}) {
2118
2665
  ...snapshotReports,
2119
2666
  ...[...previousReports.values()].filter(r => r.changes.length > 0)
2120
2667
  ];
2668
+ const operations = states();
2121
2669
  return {
2122
2670
  endpoint,
2123
2671
  ...(options.delay ? { defaultDelay: options.delay } : {}),
2124
2672
  ...(options.fallback ? { fallback: options.fallback } : {}),
2125
2673
  specs: ordered().map(summarise),
2126
- operations: states(),
2674
+ operations,
2127
2675
  lastReload,
2128
- changes
2676
+ changes,
2677
+ validation: validationInfo(operations)
2129
2678
  };
2130
2679
  }
2131
2680
  function servingOperation(method, route) {
@@ -2260,6 +2809,9 @@ function createSwaggerizer(options = {}) {
2260
2809
  for (const key of unmatchedKeys(cfg.handlers, analysed.operations)) {
2261
2810
  analysed.warnings.push(`Custom handler '${key}' matches no operation`);
2262
2811
  }
2812
+ for (const key of unmatchedIgnore(cfg.validateIgnore, analysed.operations)) {
2813
+ analysed.warnings.push(`validateIgnore entry '${key}' matches no operation`);
2814
+ }
2263
2815
  const enabled = before ? before.enabled : !disabled.has(id);
2264
2816
  next.set(id, { ...analysed, enabled, loadedAt: new Date().toISOString() });
2265
2817
  if (before && !before.error) {
@@ -2291,6 +2843,8 @@ function createSwaggerizer(options = {}) {
2291
2843
  }
2292
2844
  }
2293
2845
  specs.clear();
2846
+ // Compiled validators hold the old documents; rebuild them lazily against the new ones.
2847
+ validators.clear();
2294
2848
  for (const [id, spec] of next)
2295
2849
  specs.set(id, spec);
2296
2850
  for (const id of previousReports.keys())
@@ -2365,18 +2919,18 @@ function createSwaggerizer(options = {}) {
2365
2919
  return reload();
2366
2920
  }
2367
2921
  /* ------------------------------------------------------------------ mock serving */
2368
- async function proxy(req, res, target, relativePath, search) {
2922
+ async function proxy(req, res, target, relativePath, search, bufferedBody) {
2369
2923
  const upstream = target.replace(/\/+$/, "") + relativePath + search;
2370
2924
  const headers = {};
2371
2925
  for (const [name, value] of Object.entries(req.headers)) {
2372
2926
  if (value === undefined)
2373
2927
  continue;
2374
- if (["host", "connection", "content-length", "accept-encoding", SCENARIO_HEADER].includes(name))
2928
+ if (["host", "connection", "content-length", "accept-encoding", SCENARIO_HEADER, VALIDATION_HEADER].includes(name))
2375
2929
  continue;
2376
2930
  headers[name] = Array.isArray(value) ? value.join(", ") : value;
2377
2931
  }
2378
2932
  const method = (req.method || "GET").toUpperCase();
2379
- const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req);
2933
+ const body = method === "GET" || method === "HEAD" ? undefined : bufferedBody ?? (await readBody(req));
2380
2934
  const response = await fetch(upstream, { method, headers, body: body && body.length ? new Uint8Array(body) : undefined, redirect: "manual" });
2381
2935
  const out = Buffer.from(await response.arrayBuffer());
2382
2936
  res.statusCode = response.status;
@@ -2403,22 +2957,79 @@ function createSwaggerizer(options = {}) {
2403
2957
  const spec = specs.get(op.specId);
2404
2958
  const cfg = configFor(op.specId);
2405
2959
  const override = parseScenarioHeader(req.headers[SCENARIO_HEADER]);
2406
- const entry = { method: method.toUpperCase(), path: url.pathname + url.search, route: op.route, specId: op.specId, source: "server" };
2960
+ const mode = validationModeFor(op, req.headers[VALIDATION_HEADER]);
2961
+ const resolved = resolveResponse(op, override);
2407
2962
  const custom = lookupByOperation(cfg.handlers, op);
2408
- if (custom && !override && !op.modified) {
2409
- const raw = method === "get" || method === "head" ? undefined : (await readBody(req)).toString("utf8");
2410
- let body = raw;
2411
- if (raw !== undefined) {
2412
- try {
2413
- body = raw.trim() ? JSON.parse(raw) : undefined;
2414
- }
2415
- catch {
2416
- body = raw;
2417
- }
2963
+ const wantsHandler = Boolean(custom) && !override && !op.modified;
2964
+ const sendsBody = method !== "get" && method !== "head";
2965
+ // The request stream can only be read once: buffer it when validation, a custom handler or the
2966
+ // proxy needs it, then hand the same buffer to each of them.
2967
+ const buffered = sendsBody && (mode !== "off" || wantsHandler || resolved.kind === "proxy")
2968
+ ? await readBody(req)
2969
+ : undefined;
2970
+ const contentType = req.headers["content-type"];
2971
+ // What a custom handler has always received: parsed JSON, or the raw text when it is not JSON.
2972
+ let handlerBody;
2973
+ if (buffered && buffered.length > 0) {
2974
+ const text = buffered.toString("utf8");
2975
+ try {
2976
+ handlerBody = text.trim() ? JSON.parse(text) : undefined;
2977
+ }
2978
+ catch {
2979
+ handlerBody = text;
2980
+ }
2981
+ }
2982
+ let validationBody;
2983
+ let bodyError;
2984
+ // A whitespace-only body is no body, for validation as for the handler.
2985
+ if (mode !== "off" && buffered && buffered.toString("utf8").trim() !== "") {
2986
+ try {
2987
+ validationBody = parseRequestBody(buffered, contentType);
2418
2988
  }
2419
- const params = candidatePaths(url.pathname, spec.basePaths, cfg.aliases || {})
2420
- .map(p => extractParams(op.route, p))
2421
- .find(p => Object.keys(p).length > 0) ?? {};
2989
+ catch (err) {
2990
+ bodyError = err?.message ? String(err.message) : String(err);
2991
+ }
2992
+ }
2993
+ const pathParams = candidatePaths(url.pathname, spec.basePaths, cfg.aliases || {})
2994
+ .map(p => extractParams(op.route, p))
2995
+ .find(p => Object.keys(p).length > 0) ?? {};
2996
+ let summary;
2997
+ if (mode !== "off") {
2998
+ summary = validatorFor(spec).validateRequest(op, {
2999
+ params: pathParams,
3000
+ query: url.searchParams,
3001
+ headers: req.headers,
3002
+ ...(validationBody !== undefined ? { body: validationBody } : {}),
3003
+ ...(bodyError ? { bodyError } : {}),
3004
+ ...(contentType ? { contentType } : {})
3005
+ });
3006
+ if (summary.request.length === 0 && !summary.skipped)
3007
+ summary = undefined;
3008
+ }
3009
+ const entry = {
3010
+ method: method.toUpperCase(),
3011
+ path: url.pathname + url.search,
3012
+ route: op.route,
3013
+ specId: op.specId,
3014
+ source: "server",
3015
+ ...(summary ? { validation: summary } : {})
3016
+ };
3017
+ if (summary && mode === "enforce" && summary.request.length > 0) {
3018
+ res.setHeader("X-Swaggerizer-Validation", "failed");
3019
+ sendJson(res, 400, {
3020
+ error: "Request validation failed",
3021
+ operation: `${op.method.toUpperCase()} ${op.route}`,
3022
+ specId: op.specId,
3023
+ issues: summary.request
3024
+ });
3025
+ recordRequest({ ...entry, status: 400, example: "validation", ms: Date.now() - started });
3026
+ return true;
3027
+ }
3028
+ if (summary)
3029
+ res.setHeader("X-Swaggerizer-Validation", "warning");
3030
+ if (custom && wantsHandler) {
3031
+ const body = handlerBody;
3032
+ const params = pathParams;
2422
3033
  const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v ?? "")]));
2423
3034
  let outcome;
2424
3035
  try {
@@ -2445,7 +3056,6 @@ function createSwaggerizer(options = {}) {
2445
3056
  return true;
2446
3057
  }
2447
3058
  }
2448
- const resolved = resolveResponse(op, override);
2449
3059
  if (resolved.delay > 0)
2450
3060
  await sleep(resolved.delay);
2451
3061
  if (resolved.kind === "proxy") {
@@ -2457,7 +3067,7 @@ function createSwaggerizer(options = {}) {
2457
3067
  }
2458
3068
  const relative = relativeToBase(url.pathname, spec.basePaths, cfg.aliases || {});
2459
3069
  try {
2460
- const status = await proxy(req, res, target, relative, url.search);
3070
+ const status = await proxy(req, res, target, relative, url.search, buffered);
2461
3071
  recordRequest({ ...entry, status, example: "proxy", ms: Date.now() - started });
2462
3072
  }
2463
3073
  catch (err) {
@@ -2495,14 +3105,15 @@ function createSwaggerizer(options = {}) {
2495
3105
  reload,
2496
3106
  getRequestLog,
2497
3107
  recordRequest,
2498
- getSpecDocument: id => specs.get(id)?.doc
3108
+ getSpecDocument: id => specs.get(id)?.doc,
3109
+ getValidation: validationInfo
2499
3110
  });
2500
3111
  function handler(req, res, next) {
2501
3112
  if (options.cors !== false) {
2502
3113
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "*");
2503
3114
  res.setHeader("Vary", "Origin");
2504
3115
  res.setHeader("Access-Control-Allow-Credentials", "true");
2505
- res.setHeader("Access-Control-Expose-Headers", "X-Swaggerizer-Scenario, X-Swaggerizer-Proxied");
3116
+ res.setHeader("Access-Control-Expose-Headers", "X-Swaggerizer-Scenario, X-Swaggerizer-Proxied, X-Swaggerizer-Validation");
2506
3117
  if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
2507
3118
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS");
2508
3119
  res.setHeader("Access-Control-Allow-Headers", req.headers["access-control-request-headers"] || "*");
@@ -2596,6 +3207,7 @@ function createSwaggerizer(options = {}) {
2596
3207
  getRequestLog,
2597
3208
  recordRequest,
2598
3209
  getSpecDocument: id => specs.get(id)?.doc,
3210
+ getValidationInfo: validationInfo,
2599
3211
  listen,
2600
3212
  close,
2601
3213
  endpoint,
@@ -2819,6 +3431,7 @@ async function pull(options) {
2819
3431
  }
2820
3432
 
2821
3433
  const FAIL_ON = ["breaking", "warning", "any", "none"];
3434
+ const VALIDATE_MODES = ["off", "warn", "enforce"];
2822
3435
  const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
2823
3436
  async function confirm(reports) {
2824
3437
  if (!process.stdin.isTTY || !process.stdout.isTTY)
@@ -2873,6 +3486,10 @@ async function run(argv = process.argv) {
2873
3486
  .option("proxy", { type: "string", describe: "upstream base URL for 'Real API' mode (default: each spec's servers URL)" })
2874
3487
  .option("delay", { type: "number", describe: "default latency for every operation, in ms" })
2875
3488
  .option("fallback", { type: "string", describe: "proxy requests that match no operation to this URL" })
3489
+ .option("validate", {
3490
+ choices: VALIDATE_MODES,
3491
+ describe: "check requests against the spec: off (default), warn or enforce"
3492
+ })
2876
3493
  .option("state-file", { type: "string", describe: "persist scenario choices to this JSON file" })
2877
3494
  .option("generated", { alias: "output", type: "string", describe: "generated code folder: warn when the specs drift from it" })
2878
3495
  .option("watch", { alias: "w", type: "boolean", default: false, describe: "reload specs when files change" })
@@ -2890,6 +3507,7 @@ async function run(argv = process.argv) {
2890
3507
  proxy: args.proxy,
2891
3508
  delay: args.delay ?? config?.delay,
2892
3509
  fallback: args.fallback ?? config?.fallback,
3510
+ validate: args.validate ?? config?.validate,
2893
3511
  stateFile: args["state-file"] ?? config?.stateFile,
2894
3512
  generated: args.generated ?? config?.output,
2895
3513
  watch: args.watch,