swaggerizer 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/dist/admin.d.ts +2 -1
- package/dist/admin.d.ts.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +651 -33
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/config.mjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +654 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +651 -35
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.ts +9 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +30 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/app.css +10 -0
- package/dist/ui/app.js +25 -0
- package/dist/validate.d.ts +33 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +3 -1
- package/dist/chunks/scenario-5a1VnDmg.mjs +0 -345
- package/dist/chunks/scenario-5a1VnDmg.mjs.map +0 -1
- package/dist/chunks/scenario-BeXAfctR.mjs +0 -347
- package/dist/chunks/scenario-BeXAfctR.mjs.map +0 -1
- package/dist/chunks/scenario-CGR-NuEy.mjs +0 -371
- package/dist/chunks/scenario-CGR-NuEy.mjs.map +0 -1
- package/dist/chunks/scenario-DKCQMD5-.js +0 -364
- package/dist/chunks/scenario-DKCQMD5-.js.map +0 -1
- package/dist/chunks/scenario-yqSnVp0L.js +0 -362
- package/dist/chunks/scenario-yqSnVp0L.js.map +0 -1
package/dist/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 =>
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
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
|
|
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
|
|
2960
|
+
const mode = validationModeFor(op, req.headers[VALIDATION_HEADER]);
|
|
2961
|
+
const resolved = resolveResponse(op, override);
|
|
2407
2962
|
const custom = lookupByOperation(cfg.handlers, op);
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
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
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
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,
|