specshield 3.2.1 → 3.2.3
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/CHANGELOG.md +75 -0
- package/README.md +468 -568
- package/package.json +4 -1
- package/src/cli.js +2 -0
- package/src/commands/bdct.js +161 -1
- package/src/commands/init.js +98 -16
- package/src/commands/whoami.js +105 -0
- package/src/core/configWriter.js +30 -5
- package/src/core/conformance/index.js +52 -0
- package/src/core/conformance/pathResolver.js +108 -0
- package/src/core/conformance/probeBuilder.js +92 -0
- package/src/core/conformance/responseValidator.js +114 -0
- package/src/core/conformance/runner.js +133 -0
- package/src/core/har/emitOpenapi.js +125 -0
- package/src/core/har/index.js +68 -0
- package/src/core/har/parseHar.js +136 -0
- package/src/core/har/pathTemplate.js +92 -0
- package/src/core/har/schemaInfer.js +100 -0
- package/src/core/projectConfig.js +19 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve OpenAPI path-template parameters into concrete URL paths.
|
|
5
|
+
* Sources, in priority order:
|
|
6
|
+
* 1. caller-supplied overrides (e.g. CLI `--path-params paymentId=pay-123`)
|
|
7
|
+
* 2. the operation's `parameters[].example` for each path-param
|
|
8
|
+
* 3. the path-item-level `parameters[].example`
|
|
9
|
+
* 4. the parameter schema's `example`
|
|
10
|
+
*
|
|
11
|
+
* If a required path param can't be resolved, the probe is skipped (with a
|
|
12
|
+
* `missing` list so the runner can report it).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param template '/users/{userId}/orders/{orderId}'
|
|
17
|
+
* @param resolvedMap { userId: 'u-1', orderId: 'o-7' } (already-resolved)
|
|
18
|
+
* @returns { resolved: string, missing: string[] }
|
|
19
|
+
*/
|
|
20
|
+
function substitute(template, resolvedMap) {
|
|
21
|
+
const missing = [];
|
|
22
|
+
const resolved = template.replace(/\{([^}]+)\}/g, (_full, name) => {
|
|
23
|
+
const v = resolvedMap && Object.prototype.hasOwnProperty.call(resolvedMap, name)
|
|
24
|
+
? resolvedMap[name]
|
|
25
|
+
: undefined;
|
|
26
|
+
if (v === undefined || v === null || v === '') { missing.push(name); return `{${name}}`; }
|
|
27
|
+
return encodeURIComponent(String(v));
|
|
28
|
+
});
|
|
29
|
+
return { resolved, missing };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Walk the spec to gather examples for path params. Returns a map per-route:
|
|
34
|
+
* { '/users/{userId}': { GET: { userId: 'u-1' }, … }, … }
|
|
35
|
+
*
|
|
36
|
+
* Operation-level params override path-item-level params (standard OAS rule).
|
|
37
|
+
*/
|
|
38
|
+
function collectSpecExamples(spec) {
|
|
39
|
+
const out = {};
|
|
40
|
+
const paths = (spec && spec.paths) || {};
|
|
41
|
+
const METHODS = ['get','put','post','delete','options','head','patch','trace'];
|
|
42
|
+
|
|
43
|
+
for (const [routePath, item] of Object.entries(paths)) {
|
|
44
|
+
if (!item || typeof item !== 'object') continue;
|
|
45
|
+
out[routePath] = {};
|
|
46
|
+
|
|
47
|
+
const pathLevelParams = Array.isArray(item.parameters) ? item.parameters : [];
|
|
48
|
+
for (const method of METHODS) {
|
|
49
|
+
const op = item[method];
|
|
50
|
+
if (!op || typeof op !== 'object') continue;
|
|
51
|
+
const opParams = Array.isArray(op.parameters) ? op.parameters : [];
|
|
52
|
+
|
|
53
|
+
const merged = {};
|
|
54
|
+
for (const p of pathLevelParams) addExample(merged, p);
|
|
55
|
+
for (const p of opParams) addExample(merged, p); // op overrides
|
|
56
|
+
out[routePath][method.toUpperCase()] = merged;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function addExample(target, param) {
|
|
63
|
+
if (!param || param.in !== 'path' || !param.name) return;
|
|
64
|
+
const ex = param.example
|
|
65
|
+
?? (param.examples && firstExampleValue(param.examples))
|
|
66
|
+
?? (param.schema && param.schema.example);
|
|
67
|
+
if (ex !== undefined) target[param.name] = ex;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function firstExampleValue(examples) {
|
|
71
|
+
for (const v of Object.values(examples || {})) {
|
|
72
|
+
if (v && v.value !== undefined) return v.value;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a single probe's path. CLI overrides win over spec examples.
|
|
79
|
+
*
|
|
80
|
+
* @param routePath '/users/{userId}'
|
|
81
|
+
* @param method 'GET'
|
|
82
|
+
* @param specExamples output of collectSpecExamples(spec)
|
|
83
|
+
* @param cliOverrides { userId: 'u-7', ... } (global)
|
|
84
|
+
*/
|
|
85
|
+
function resolveProbePath(routePath, method, specExamples, cliOverrides) {
|
|
86
|
+
const fromSpec = (specExamples[routePath] && specExamples[routePath][method.toUpperCase()]) || {};
|
|
87
|
+
const merged = { ...fromSpec, ...(cliOverrides || {}) };
|
|
88
|
+
return substitute(routePath, merged);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse a CLI string `paymentId=pay-123,userId=u-7` into a map.
|
|
93
|
+
* Multiple `--path-params` flags can be joined by the caller before parsing.
|
|
94
|
+
*/
|
|
95
|
+
function parsePathParamsArg(arg) {
|
|
96
|
+
if (!arg) return {};
|
|
97
|
+
const map = {};
|
|
98
|
+
for (const pair of String(arg).split(',')) {
|
|
99
|
+
const [k, ...rest] = pair.split('=');
|
|
100
|
+
if (!k) continue;
|
|
101
|
+
map[k.trim()] = rest.join('=').trim();
|
|
102
|
+
}
|
|
103
|
+
return map;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
substitute, collectSpecExamples, resolveProbePath, parsePathParamsArg,
|
|
108
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a flat list of conformance probes from a dereferenced OAS document.
|
|
5
|
+
*
|
|
6
|
+
* Probe = {
|
|
7
|
+
* routePath: '/users/{userId}',
|
|
8
|
+
* method: 'GET',
|
|
9
|
+
* operationId: string | undefined,
|
|
10
|
+
* expectedResponses: { '200': schema, '404': schema, default?: schema },
|
|
11
|
+
* responseHeadersForStatus: { '200': { 'X-RateLimit-Remaining': { schema } } },
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Safety: by default we only probe **safe** methods (GET, HEAD, OPTIONS).
|
|
15
|
+
* Mutating verbs are opt-in via `includeMutating: true` so we never
|
|
16
|
+
* accidentally side-effect a customer's staging data.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SAFE_METHODS = new Set(['get', 'head', 'options']);
|
|
20
|
+
const ALL_METHODS = ['get','put','post','delete','options','head','patch','trace'];
|
|
21
|
+
const JSON_MIME = /\bjson\b/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param spec dereferenced OAS document
|
|
25
|
+
* @param opts.includeMutating default false
|
|
26
|
+
* @returns Probe[]
|
|
27
|
+
*/
|
|
28
|
+
function buildProbes(spec, opts = {}) {
|
|
29
|
+
const includeMutating = opts.includeMutating === true;
|
|
30
|
+
const allowed = includeMutating ? new Set(ALL_METHODS) : SAFE_METHODS;
|
|
31
|
+
const out = [];
|
|
32
|
+
|
|
33
|
+
const paths = (spec && spec.paths) || {};
|
|
34
|
+
for (const [routePath, item] of Object.entries(paths)) {
|
|
35
|
+
if (!item || typeof item !== 'object') continue;
|
|
36
|
+
for (const method of ALL_METHODS) {
|
|
37
|
+
const op = item[method];
|
|
38
|
+
if (!op || typeof op !== 'object') continue;
|
|
39
|
+
if (!allowed.has(method)) continue;
|
|
40
|
+
|
|
41
|
+
out.push({
|
|
42
|
+
routePath,
|
|
43
|
+
method: method.toUpperCase(),
|
|
44
|
+
operationId: op.operationId,
|
|
45
|
+
expectedResponses: extractResponses(op.responses || {}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* { '200': { content: { 'application/json': { schema } } }, '4XX': … }
|
|
54
|
+
* →
|
|
55
|
+
* { '200': schema | null, '4XX': schema | null, default?: schema | null }
|
|
56
|
+
*
|
|
57
|
+
* Pulls the JSON-content schema only (other content types deferred).
|
|
58
|
+
*/
|
|
59
|
+
function extractResponses(responses) {
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [code, body] of Object.entries(responses)) {
|
|
62
|
+
if (!body) continue;
|
|
63
|
+
const content = body.content || {};
|
|
64
|
+
let schema = null;
|
|
65
|
+
for (const [mime, c] of Object.entries(content)) {
|
|
66
|
+
if (JSON_MIME.test(mime) && c && c.schema) { schema = c.schema; break; }
|
|
67
|
+
}
|
|
68
|
+
out[code] = schema; // null = "documented status, no JSON schema"
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Match an actual response status (e.g. 200) against the spec's response keys
|
|
75
|
+
* (which can be exact `"200"`, wildcard `"2XX"`, or `"default"`).
|
|
76
|
+
* Returns the matching schema (possibly null) or undefined if nothing matches.
|
|
77
|
+
*/
|
|
78
|
+
function pickResponseSchema(probe, actualStatus) {
|
|
79
|
+
const r = probe.expectedResponses;
|
|
80
|
+
const code = String(actualStatus);
|
|
81
|
+
if (Object.prototype.hasOwnProperty.call(r, code)) return r[code];
|
|
82
|
+
const wildcard = code[0] + 'XX';
|
|
83
|
+
if (Object.prototype.hasOwnProperty.call(r, wildcard)) return r[wildcard];
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(r, wildcard.toLowerCase())) return r[wildcard.toLowerCase()];
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(r, 'default')) return r['default'];
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
buildProbes, extractResponses, pickResponseSchema,
|
|
91
|
+
SAFE_METHODS, ALL_METHODS,
|
|
92
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate an actual HTTP response body against the OAS response schema —
|
|
5
|
+
* the core of Fix 3 (spec-vs-production conformance).
|
|
6
|
+
*
|
|
7
|
+
* OpenAPI 3.0 schemas are a *modified subset* of JSON Schema. ajv validates
|
|
8
|
+
* standard JSON Schema, so we normalise OAS-isms first:
|
|
9
|
+
*
|
|
10
|
+
* - `nullable: true` → union with null (`type: [x, 'null']`)
|
|
11
|
+
* - `example`, `examples`, `xml`, `discriminator`, `readOnly`, `writeOnly`,
|
|
12
|
+
* `deprecated`, `externalDocs` → stripped (annotations only)
|
|
13
|
+
*
|
|
14
|
+
* Anything else (allOf/oneOf/anyOf/not, formats, enums, required, additional-
|
|
15
|
+
* Properties) passes through to ajv unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Format keywords (date-time, uuid, email, …) are handled by ajv-formats.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const Ajv = require('ajv').default;
|
|
21
|
+
const addFormats = require('ajv-formats');
|
|
22
|
+
|
|
23
|
+
// One ajv per-validator-call would be slow; cache compiled validators by
|
|
24
|
+
// schema reference. Lifetime is the process — for the CLI that's fine.
|
|
25
|
+
const ajv = new Ajv({
|
|
26
|
+
strict: false, // OAS allows non-standard keywords; don't fail compile
|
|
27
|
+
allErrors: true, // collect every mismatch, not just the first
|
|
28
|
+
validateFormats: true,
|
|
29
|
+
coerceTypes: false, // a body field that's "1" when spec says integer = mismatch
|
|
30
|
+
});
|
|
31
|
+
addFormats(ajv);
|
|
32
|
+
|
|
33
|
+
const compiledCache = new WeakMap();
|
|
34
|
+
|
|
35
|
+
function compile(schema) {
|
|
36
|
+
if (compiledCache.has(schema)) return compiledCache.get(schema);
|
|
37
|
+
const normalised = oasToJsonSchema(schema);
|
|
38
|
+
const fn = ajv.compile(normalised);
|
|
39
|
+
compiledCache.set(schema, fn);
|
|
40
|
+
return fn;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Recursively rewrite OAS-3.0 quirks into plain JSON Schema. */
|
|
44
|
+
function oasToJsonSchema(node) {
|
|
45
|
+
if (node === null || typeof node !== 'object') return node;
|
|
46
|
+
if (Array.isArray(node)) return node.map(oasToJsonSchema);
|
|
47
|
+
|
|
48
|
+
// Drop OAS-only annotations that confuse ajv (or are no-ops for validation).
|
|
49
|
+
const STRIP = new Set([
|
|
50
|
+
'example', 'examples', 'xml', 'discriminator',
|
|
51
|
+
'readOnly', 'writeOnly', 'deprecated', 'externalDocs',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [k, v] of Object.entries(node)) {
|
|
56
|
+
if (STRIP.has(k)) continue;
|
|
57
|
+
if (k === 'nullable') continue; // handled below
|
|
58
|
+
out[k] = oasToJsonSchema(v);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `nullable: true` → widen type to also permit null. JSON Schema 2020-12 +
|
|
62
|
+
// ajv accept `type: [...]` arrays.
|
|
63
|
+
if (node.nullable === true && out.type) {
|
|
64
|
+
if (Array.isArray(out.type)) {
|
|
65
|
+
if (!out.type.includes('null')) out.type = [...out.type, 'null'];
|
|
66
|
+
} else {
|
|
67
|
+
out.type = [out.type, 'null'];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate one body against one schema.
|
|
76
|
+
* Returns { ok: true } on pass; otherwise { ok: false, errors: [...] } where
|
|
77
|
+
* each error is { path, message, expected?, got? } — caller-friendly format.
|
|
78
|
+
*/
|
|
79
|
+
function validateBody(body, schema) {
|
|
80
|
+
if (!schema) return { ok: true, errors: [] };
|
|
81
|
+
let validate;
|
|
82
|
+
try { validate = compile(schema); }
|
|
83
|
+
catch (e) {
|
|
84
|
+
return { ok: false, errors: [{
|
|
85
|
+
path: '', message: `spec schema is invalid: ${e.message}`,
|
|
86
|
+
}]};
|
|
87
|
+
}
|
|
88
|
+
const ok = validate(body);
|
|
89
|
+
if (ok) return { ok: true, errors: [] };
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
errors: (validate.errors || []).map(e => ({
|
|
93
|
+
path: e.instancePath || '(root)',
|
|
94
|
+
keyword: e.keyword, // 'enum' | 'required' | 'type' | 'format' | …
|
|
95
|
+
message: e.message || 'validation failed',
|
|
96
|
+
expected: e.params, // ajv's params, e.g. { allowedValues, missingProperty, format, type }
|
|
97
|
+
got: peek(body, e.instancePath),
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Best-effort: extract the value at a JSON-Pointer-style path for the error. */
|
|
103
|
+
function peek(body, jsonPointer) {
|
|
104
|
+
if (!jsonPointer || jsonPointer === '') return body;
|
|
105
|
+
const parts = jsonPointer.split('/').slice(1).map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
106
|
+
let cur = body;
|
|
107
|
+
for (const p of parts) {
|
|
108
|
+
if (cur === null || cur === undefined) return undefined;
|
|
109
|
+
cur = cur[p];
|
|
110
|
+
}
|
|
111
|
+
return cur;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { validateBody, oasToJsonSchema };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Execute a set of conformance probes against the running provider.
|
|
5
|
+
* Pure function-ish: takes probes + an `http(method, url, opts)` adapter
|
|
6
|
+
* (so tests can inject without spinning a real HTTP client) and returns
|
|
7
|
+
* a structured result list.
|
|
8
|
+
*
|
|
9
|
+
* ProbeResult = {
|
|
10
|
+
* routePath: '/users/{userId}',
|
|
11
|
+
* method: 'GET',
|
|
12
|
+
* resolvedPath: '/users/u-7', // or null if skipped
|
|
13
|
+
* status: 'PASS' | 'FAIL' | 'SKIPPED' | 'ERROR',
|
|
14
|
+
* httpStatus: 200, // if reached
|
|
15
|
+
* reason: human-readable summary,
|
|
16
|
+
* mismatches: [{path, message, expected, got}, …], // only on FAIL
|
|
17
|
+
* skipReason: 'unresolved path params: paymentId', // only on SKIPPED
|
|
18
|
+
* error: 'ECONNREFUSED …', // only on ERROR
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* RunSummary = { total, pass, fail, skipped, error }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { resolveProbePath, collectSpecExamples } = require('./pathResolver');
|
|
25
|
+
const { pickResponseSchema } = require('./probeBuilder');
|
|
26
|
+
const { validateBody } = require('./responseValidator');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param spec dereferenced OAS (used to gather param examples)
|
|
30
|
+
* @param probes output of buildProbes(spec)
|
|
31
|
+
* @param opts.baseUrl e.g. 'https://staging.payments.acme.com'
|
|
32
|
+
* @param opts.pathParams { paymentId: 'pay-123', ... } CLI overrides
|
|
33
|
+
* @param opts.headers request headers to send (e.g. auth)
|
|
34
|
+
* @param opts.http async (method, url, {headers, timeoutMs}) → {status, body}
|
|
35
|
+
* @param opts.timeoutMs default 8000
|
|
36
|
+
*/
|
|
37
|
+
async function runProbes(spec, probes, opts) {
|
|
38
|
+
const examples = collectSpecExamples(spec);
|
|
39
|
+
const results = [];
|
|
40
|
+
const http = opts.http;
|
|
41
|
+
const baseUrl = String(opts.baseUrl || '').replace(/\/$/, '');
|
|
42
|
+
|
|
43
|
+
for (const probe of probes) {
|
|
44
|
+
const { resolved, missing } = resolveProbePath(
|
|
45
|
+
probe.routePath, probe.method, examples, opts.pathParams,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
results.push({
|
|
50
|
+
routePath: probe.routePath, method: probe.method,
|
|
51
|
+
resolvedPath: null, status: 'SKIPPED',
|
|
52
|
+
reason: `unresolved path params (no --path-params or spec example)`,
|
|
53
|
+
skipReason: missing.join(', '),
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const url = baseUrl + resolved;
|
|
59
|
+
let httpStatus, body, err;
|
|
60
|
+
try {
|
|
61
|
+
const r = await http(probe.method, url, {
|
|
62
|
+
headers: opts.headers || {},
|
|
63
|
+
timeoutMs: opts.timeoutMs || 8000,
|
|
64
|
+
});
|
|
65
|
+
httpStatus = r.status;
|
|
66
|
+
body = r.body;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
err = e.message || String(e);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (err) {
|
|
72
|
+
results.push({
|
|
73
|
+
routePath: probe.routePath, method: probe.method,
|
|
74
|
+
resolvedPath: resolved, status: 'ERROR',
|
|
75
|
+
reason: 'HTTP call failed', error: err,
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const schema = pickResponseSchema(probe, httpStatus);
|
|
81
|
+
if (schema === undefined) {
|
|
82
|
+
results.push({
|
|
83
|
+
routePath: probe.routePath, method: probe.method,
|
|
84
|
+
resolvedPath: resolved, status: 'FAIL', httpStatus,
|
|
85
|
+
reason: `actual status ${httpStatus} is not documented in the spec`,
|
|
86
|
+
mismatches: [],
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (schema === null) {
|
|
91
|
+
// Status documented but no JSON schema — accept any response body.
|
|
92
|
+
results.push({
|
|
93
|
+
routePath: probe.routePath, method: probe.method,
|
|
94
|
+
resolvedPath: resolved, status: 'PASS', httpStatus,
|
|
95
|
+
reason: 'status documented (no JSON schema to validate against)',
|
|
96
|
+
mismatches: [],
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { ok, errors } = validateBody(body, schema);
|
|
102
|
+
if (ok) {
|
|
103
|
+
results.push({
|
|
104
|
+
routePath: probe.routePath, method: probe.method,
|
|
105
|
+
resolvedPath: resolved, status: 'PASS', httpStatus,
|
|
106
|
+
reason: 'response matches spec schema',
|
|
107
|
+
mismatches: [],
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
results.push({
|
|
111
|
+
routePath: probe.routePath, method: probe.method,
|
|
112
|
+
resolvedPath: resolved, status: 'FAIL', httpStatus,
|
|
113
|
+
reason: `response body does not match spec (${errors.length} mismatch${errors.length === 1 ? '' : 'es'})`,
|
|
114
|
+
mismatches: errors,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { results, summary: summarise(results) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function summarise(results) {
|
|
123
|
+
const s = { total: results.length, pass: 0, fail: 0, skipped: 0, error: 0 };
|
|
124
|
+
for (const r of results) {
|
|
125
|
+
if (r.status === 'PASS') s.pass++;
|
|
126
|
+
else if (r.status === 'FAIL') s.fail++;
|
|
127
|
+
else if (r.status === 'SKIPPED') s.skipped++;
|
|
128
|
+
else if (r.status === 'ERROR') s.error++;
|
|
129
|
+
}
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { runProbes, summarise };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Group normalised HAR records by (method, templated-path) and emit an
|
|
5
|
+
* OpenAPI 3.0 document. Per-status response schemas are merged across all
|
|
6
|
+
* samples for that group so the emitted contract describes what the
|
|
7
|
+
* consumer ACTUALLY read across the captured run — not just one example.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { templatePath } = require('./pathTemplate');
|
|
11
|
+
const { inferSchema, mergeSchemas } = require('./schemaInfer');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param records normalised HAR records (output of normaliseEntries())
|
|
15
|
+
* @param opts { title, version, baseUrl, dynamicPatterns }
|
|
16
|
+
* @returns OpenAPI 3.0 document (plain JS object)
|
|
17
|
+
*/
|
|
18
|
+
function harToOpenapi(records, opts = {}) {
|
|
19
|
+
const groups = new Map(); // key: "METHOD /templated/path"
|
|
20
|
+
|
|
21
|
+
for (const r of records) {
|
|
22
|
+
const { templated, paramNames } = templatePath(r.path, {
|
|
23
|
+
dynamicPatterns: opts.dynamicPatterns,
|
|
24
|
+
});
|
|
25
|
+
const key = `${r.method} ${templated}`;
|
|
26
|
+
let g = groups.get(key);
|
|
27
|
+
if (!g) {
|
|
28
|
+
g = {
|
|
29
|
+
method: r.method.toLowerCase(),
|
|
30
|
+
path: templated,
|
|
31
|
+
paramNames,
|
|
32
|
+
requestSchema: undefined,
|
|
33
|
+
responses: new Map(), // status → merged schema (or null = saw status, no JSON body)
|
|
34
|
+
};
|
|
35
|
+
groups.set(key, g);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (r.requestBody !== undefined) {
|
|
39
|
+
const sample = inferSchema(r.requestBody);
|
|
40
|
+
g.requestSchema = g.requestSchema ? mergeSchemas(g.requestSchema, sample) : sample;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (r.responseStatus) {
|
|
44
|
+
const status = String(r.responseStatus);
|
|
45
|
+
const schema = r.responseBody !== undefined ? inferSchema(r.responseBody) : null;
|
|
46
|
+
if (schema) {
|
|
47
|
+
const existing = g.responses.get(status);
|
|
48
|
+
g.responses.set(status, existing ? mergeSchemas(existing, schema) : schema);
|
|
49
|
+
} else if (!g.responses.has(status)) {
|
|
50
|
+
g.responses.set(status, null);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build the OpenAPI doc.
|
|
56
|
+
const paths = {};
|
|
57
|
+
for (const g of groups.values()) {
|
|
58
|
+
const pItem = paths[g.path] || (paths[g.path] = {});
|
|
59
|
+
const op = {};
|
|
60
|
+
|
|
61
|
+
if (g.paramNames.length > 0) {
|
|
62
|
+
op.parameters = g.paramNames.map(name => ({
|
|
63
|
+
name, in: 'path', required: true, schema: { type: 'string' },
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (g.requestSchema) {
|
|
68
|
+
op.requestBody = {
|
|
69
|
+
required: true,
|
|
70
|
+
content: { 'application/json': { schema: g.requestSchema } },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const responses = {};
|
|
75
|
+
if (g.responses.size === 0) {
|
|
76
|
+
responses['200'] = { description: 'OK' };
|
|
77
|
+
} else {
|
|
78
|
+
// Sort numerically for stable output.
|
|
79
|
+
const statuses = [...g.responses.keys()].sort((a, b) => Number(a) - Number(b));
|
|
80
|
+
for (const status of statuses) {
|
|
81
|
+
const schema = g.responses.get(status);
|
|
82
|
+
const r = { description: defaultDescriptionFor(status) };
|
|
83
|
+
if (schema) r.content = { 'application/json': { schema } };
|
|
84
|
+
responses[status] = r;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
op.responses = responses;
|
|
88
|
+
|
|
89
|
+
pItem[g.method] = op;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const doc = {
|
|
93
|
+
openapi: '3.0.0',
|
|
94
|
+
info: {
|
|
95
|
+
title: opts.title || 'Captured consumer contract',
|
|
96
|
+
version: opts.version || '0.1.0',
|
|
97
|
+
description:
|
|
98
|
+
'Generated by `specshield bdct capture` from recorded HAR traffic. ' +
|
|
99
|
+
'Reflects only the endpoints/fields the consumer actually called/read.',
|
|
100
|
+
},
|
|
101
|
+
paths: sortObject(paths), // stable, alphabetical output
|
|
102
|
+
};
|
|
103
|
+
if (opts.baseUrl) doc.servers = [{ url: String(opts.baseUrl) }];
|
|
104
|
+
// Re-order top-level so `servers` appears between `info` and `paths` (idiomatic).
|
|
105
|
+
if (opts.baseUrl) {
|
|
106
|
+
return { openapi: doc.openapi, info: doc.info, servers: doc.servers, paths: doc.paths };
|
|
107
|
+
}
|
|
108
|
+
return doc;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function defaultDescriptionFor(status) {
|
|
112
|
+
const n = Number(status);
|
|
113
|
+
if (n >= 200 && n < 300) return 'Successful response';
|
|
114
|
+
if (n >= 300 && n < 400) return 'Redirection';
|
|
115
|
+
if (n >= 400 && n < 500) return 'Client error';
|
|
116
|
+
if (n >= 500) return 'Server error';
|
|
117
|
+
return 'Response';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sortObject(o) {
|
|
121
|
+
if (!o || typeof o !== 'object') return o;
|
|
122
|
+
return Object.keys(o).sort().reduce((acc, k) => { acc[k] = o[k]; return acc; }, {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { harToOpenapi };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-call orchestrator for `specshield bdct capture from-har`. Read a HAR
|
|
5
|
+
* file → produce an OpenAPI 3.0 document (text + parsed) + a small summary
|
|
6
|
+
* the CLI can print.
|
|
7
|
+
*
|
|
8
|
+
* This is Phase 1 of Fix 2 (the BDCT fidelity-roadmap "capture" feature):
|
|
9
|
+
* HAR ingest specifically — language-agnostic, no TLS-proxy gymnastics.
|
|
10
|
+
* Later phases can layer on live capture (Node `--require` interceptor,
|
|
11
|
+
* full mitm proxy) without changing the downstream pipeline.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const yaml = require('js-yaml');
|
|
15
|
+
const { readHarFile, normaliseEntries } = require('./parseHar');
|
|
16
|
+
const { harToOpenapi } = require('./emitOpenapi');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param filepath path to a HAR file
|
|
20
|
+
* @param opts { baseUrl, methods, onlyJson, title, version, format,
|
|
21
|
+
* dynamicPatterns }
|
|
22
|
+
* @returns { text, doc, summary }
|
|
23
|
+
*
|
|
24
|
+
* text — the OpenAPI document serialised (default: YAML)
|
|
25
|
+
* doc — the same document as a JS object
|
|
26
|
+
* summary — { harEntries, recordsKept, endpoints, operations }
|
|
27
|
+
*/
|
|
28
|
+
function captureFromHarFile(filepath, opts = {}) {
|
|
29
|
+
const har = readHarFile(filepath);
|
|
30
|
+
const records = normaliseEntries(har, {
|
|
31
|
+
baseUrl: opts.baseUrl,
|
|
32
|
+
methods: opts.methods,
|
|
33
|
+
onlyJson: opts.onlyJson,
|
|
34
|
+
});
|
|
35
|
+
const doc = harToOpenapi(records, {
|
|
36
|
+
title: opts.title,
|
|
37
|
+
version: opts.version,
|
|
38
|
+
baseUrl: opts.baseUrl,
|
|
39
|
+
dynamicPatterns: opts.dynamicPatterns,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const fmt = String(opts.format || 'yaml').toLowerCase();
|
|
43
|
+
const text = fmt === 'json'
|
|
44
|
+
? JSON.stringify(doc, null, 2) + '\n'
|
|
45
|
+
: yaml.dump(doc, { noRefs: true, sortKeys: false, lineWidth: 120 });
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
text,
|
|
49
|
+
doc,
|
|
50
|
+
summary: {
|
|
51
|
+
harEntries: har.log.entries.length,
|
|
52
|
+
recordsKept: records.length,
|
|
53
|
+
endpoints: Object.keys(doc.paths || {}).length,
|
|
54
|
+
operations: countOperations(doc),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function countOperations(doc) {
|
|
60
|
+
let n = 0;
|
|
61
|
+
const methods = ['get','post','put','patch','delete','head','options','trace'];
|
|
62
|
+
for (const item of Object.values(doc.paths || {})) {
|
|
63
|
+
for (const k of Object.keys(item)) if (methods.includes(k)) n++;
|
|
64
|
+
}
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { captureFromHarFile };
|