specshield 3.2.2 → 3.2.4
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 +180 -2
- package/src/commands/whoami.js +105 -0
- 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/util/versionStrip.js +24 -0
|
@@ -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 };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read + normalise a HAR (HTTP Archive 1.2) file into the minimal record
|
|
5
|
+
* shape the OpenAPI emitter consumes.
|
|
6
|
+
*
|
|
7
|
+
* readHarFile(path) → parsed HAR object (validates shape)
|
|
8
|
+
* normaliseEntries(har, opts) → HarRecord[]
|
|
9
|
+
*
|
|
10
|
+
* HarRecord = {
|
|
11
|
+
* method: 'GET' | 'POST' | …,
|
|
12
|
+
* url: URL object,
|
|
13
|
+
* path: '/users/123',
|
|
14
|
+
* query: { limit: '10', … },
|
|
15
|
+
* requestBody: parsed JSON | undefined,
|
|
16
|
+
* responseStatus: 200,
|
|
17
|
+
* responseBody: parsed JSON | undefined,
|
|
18
|
+
* requestContentType: 'application/json' | null,
|
|
19
|
+
* responseContentType: 'application/json' | null,
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const { URL } = require('url');
|
|
25
|
+
|
|
26
|
+
function isJsonMimeType(m) {
|
|
27
|
+
if (!m) return false;
|
|
28
|
+
return /\bjson\b/i.test(m);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeJsonParse(text) {
|
|
32
|
+
if (typeof text !== 'string' || text.trim().length === 0) return undefined;
|
|
33
|
+
try { return JSON.parse(text); } catch { return undefined; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readHarFile(filepath) {
|
|
37
|
+
const raw = fs.readFileSync(filepath, 'utf8');
|
|
38
|
+
let doc;
|
|
39
|
+
try { doc = JSON.parse(raw); }
|
|
40
|
+
catch (e) { throw new Error(`Not valid HAR JSON: ${filepath} (${e.message})`); }
|
|
41
|
+
if (!doc || typeof doc !== 'object' || !doc.log || !Array.isArray(doc.log.entries)) {
|
|
42
|
+
throw new Error(`Not a HAR file (missing log.entries): ${filepath}`);
|
|
43
|
+
}
|
|
44
|
+
return doc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param har parsed HAR object
|
|
49
|
+
* @param opts.baseUrl filter: keep only entries whose URL host (and optional
|
|
50
|
+
* prefix path, e.g. /v1) matches. Bare host accepted.
|
|
51
|
+
* @param opts.methods filter: keep only these HTTP methods (case-insensitive)
|
|
52
|
+
* @param opts.onlyJson default true — drop entries whose bodies are non-JSON
|
|
53
|
+
*/
|
|
54
|
+
function normaliseEntries(har, opts = {}) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const entries = har.log.entries;
|
|
57
|
+
const baseUrl = opts.baseUrl ? normaliseBase(opts.baseUrl) : null;
|
|
58
|
+
const methods = Array.isArray(opts.methods) && opts.methods.length > 0
|
|
59
|
+
? new Set(opts.methods.map(m => m.toUpperCase()))
|
|
60
|
+
: null;
|
|
61
|
+
const onlyJson = opts.onlyJson !== false;
|
|
62
|
+
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if (!e || !e.request) continue;
|
|
65
|
+
let url;
|
|
66
|
+
try { url = new URL(e.request.url); } catch { continue; }
|
|
67
|
+
|
|
68
|
+
const method = String(e.request.method || 'GET').toUpperCase();
|
|
69
|
+
if (methods && !methods.has(method)) continue;
|
|
70
|
+
if (baseUrl && !urlMatchesBase(url, baseUrl)) continue;
|
|
71
|
+
|
|
72
|
+
const reqMime = headerValue(e.request.headers, 'content-type');
|
|
73
|
+
const resMime = e.response && e.response.content && e.response.content.mimeType;
|
|
74
|
+
|
|
75
|
+
const requestBody = safeJsonParse(e.request.postData && e.request.postData.text);
|
|
76
|
+
const responseBody = safeJsonParse(e.response && e.response.content && e.response.content.text);
|
|
77
|
+
|
|
78
|
+
// Drop entries whose declared body is non-JSON (or undecodable). We can't
|
|
79
|
+
// infer a JSON Schema from binary/multipart/HTML, and silently emitting
|
|
80
|
+
// a wrong schema would be worse than dropping the sample.
|
|
81
|
+
if (onlyJson) {
|
|
82
|
+
const hasReqText = e.request.postData && typeof e.request.postData.text === 'string'
|
|
83
|
+
&& e.request.postData.text.length > 0;
|
|
84
|
+
if (hasReqText && requestBody === undefined && !isJsonMimeType(reqMime)) continue;
|
|
85
|
+
|
|
86
|
+
const hasResText = e.response && e.response.content
|
|
87
|
+
&& typeof e.response.content.text === 'string'
|
|
88
|
+
&& e.response.content.text.length > 0;
|
|
89
|
+
if (hasResText && responseBody === undefined && !isJsonMimeType(resMime)) continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const query = {};
|
|
93
|
+
url.searchParams.forEach((v, k) => { query[k] = v; });
|
|
94
|
+
|
|
95
|
+
out.push({
|
|
96
|
+
method,
|
|
97
|
+
url,
|
|
98
|
+
path: url.pathname,
|
|
99
|
+
query,
|
|
100
|
+
requestBody,
|
|
101
|
+
responseStatus: e.response ? e.response.status : 0,
|
|
102
|
+
responseBody,
|
|
103
|
+
requestContentType: reqMime || null,
|
|
104
|
+
responseContentType: resMime || null,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function headerValue(headers, name) {
|
|
111
|
+
if (!Array.isArray(headers)) return null;
|
|
112
|
+
const wanted = name.toLowerCase();
|
|
113
|
+
for (const h of headers) {
|
|
114
|
+
if (h && typeof h.name === 'string' && h.name.toLowerCase() === wanted) return h.value;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normaliseBase(base) {
|
|
120
|
+
if (!base) return null;
|
|
121
|
+
if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
|
|
122
|
+
try { return new URL(base); } catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function urlMatchesBase(url, baseUrl) {
|
|
126
|
+
if (!baseUrl) return true;
|
|
127
|
+
if (url.host !== baseUrl.host) return false;
|
|
128
|
+
if (url.protocol !== baseUrl.protocol) return false;
|
|
129
|
+
const basePath = baseUrl.pathname.replace(/\/$/, '');
|
|
130
|
+
if (basePath && basePath !== '/' &&
|
|
131
|
+
!url.pathname.startsWith(basePath + '/') &&
|
|
132
|
+
url.pathname !== basePath) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { readHarFile, normaliseEntries, isJsonMimeType };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Path-template inference for HAR → OpenAPI capture (Fix 2 of the BDCT
|
|
5
|
+
* fidelity roadmap).
|
|
6
|
+
*
|
|
7
|
+
* Goal: turn concrete request paths recorded in real traffic into OpenAPI
|
|
8
|
+
* path templates, e.g.
|
|
9
|
+
* /users/123/orders/550e8400-e29b-41d4-a716-446655440000
|
|
10
|
+
* → /users/{userId}/orders/{orderId}
|
|
11
|
+
*
|
|
12
|
+
* Heuristic (intentionally conservative — false positives cost more than
|
|
13
|
+
* false negatives because over-templating destroys meaningful endpoint
|
|
14
|
+
* shape):
|
|
15
|
+
*
|
|
16
|
+
* A segment is "dynamic" iff it matches one of:
|
|
17
|
+
* - all digits e.g. "123", "42"
|
|
18
|
+
* - UUID e.g. "550e8400-…"
|
|
19
|
+
* - hex-only ≥ 8 chars e.g. "9a3f7c1b" (mongo-style ids)
|
|
20
|
+
* - has a digit AND a hyphen/underscore e.g. "PAY-2026-001"
|
|
21
|
+
*
|
|
22
|
+
* Pure-alpha segments ("orders", "summary", "profile") stay literal.
|
|
23
|
+
*
|
|
24
|
+
* Param naming: if the preceding segment is alpha (a resource name), the
|
|
25
|
+
* synthesised param is "<singular>Id" (rough singularise: strip trailing
|
|
26
|
+
* `s`), so /users/123 → /users/{userId}. Otherwise it's plain "id".
|
|
27
|
+
*
|
|
28
|
+
* Custom dynamic patterns can be supplied via `opts.dynamicPatterns` —
|
|
29
|
+
* an array of RegExp matched against each segment.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
33
|
+
const HEX_RE = /^[0-9a-f]{8,}$/i;
|
|
34
|
+
const DIGITS_RE = /^\d+$/;
|
|
35
|
+
|
|
36
|
+
function isDynamicSegment(seg, opts = {}) {
|
|
37
|
+
if (!seg) return false;
|
|
38
|
+
if (DIGITS_RE.test(seg)) return true;
|
|
39
|
+
if (UUID_RE.test(seg)) return true;
|
|
40
|
+
if (HEX_RE.test(seg)) return true;
|
|
41
|
+
if (/[-_]/.test(seg) && /\d/.test(seg)) return true;
|
|
42
|
+
if (Array.isArray(opts.dynamicPatterns)) {
|
|
43
|
+
for (const re of opts.dynamicPatterns) if (re.test(seg)) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function paramNameFromContext(prevSegment) {
|
|
49
|
+
if (!prevSegment || !/^[a-z][a-z0-9_-]*$/i.test(prevSegment)) return 'id';
|
|
50
|
+
// Rough singularisation: users → user, orders → order, but "address" → "addres"
|
|
51
|
+
// is wrong. Only apply when the result still has >1 char AND the original
|
|
52
|
+
// ends in a plural-looking 's' (not 'ss' which is usually mass-noun).
|
|
53
|
+
const m = /^(.+?)s$/.exec(prevSegment);
|
|
54
|
+
const base = (m && !/ss$/.test(prevSegment)) ? m[1] : prevSegment;
|
|
55
|
+
if (!base || base.length < 2) return 'id';
|
|
56
|
+
return `${base}Id`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert a concrete path (no query string) into a templated path.
|
|
61
|
+
* Returns { templated, paramNames }.
|
|
62
|
+
*/
|
|
63
|
+
function templatePath(concretePath, opts = {}) {
|
|
64
|
+
if (typeof concretePath !== 'string' || concretePath.length === 0) {
|
|
65
|
+
return { templated: '/', paramNames: [] };
|
|
66
|
+
}
|
|
67
|
+
// Strip query if caller accidentally passed it.
|
|
68
|
+
const q = concretePath.indexOf('?');
|
|
69
|
+
const clean = q >= 0 ? concretePath.slice(0, q) : concretePath;
|
|
70
|
+
|
|
71
|
+
const segments = clean.split('/');
|
|
72
|
+
const paramNames = [];
|
|
73
|
+
// De-duplicate within one path so /users/1/users/2 → /users/{userId}/users/{userId2}
|
|
74
|
+
const used = new Map();
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < segments.length; i++) {
|
|
77
|
+
const s = segments[i];
|
|
78
|
+
if (!s) continue;
|
|
79
|
+
if (isDynamicSegment(s, opts)) {
|
|
80
|
+
let name = paramNameFromContext(segments[i - 1]);
|
|
81
|
+
const seen = used.get(name) || 0;
|
|
82
|
+
if (seen > 0) name = `${name}${seen + 1}`;
|
|
83
|
+
used.set(name.replace(/\d+$/, '') || name, seen + 1);
|
|
84
|
+
segments[i] = `{${name}}`;
|
|
85
|
+
paramNames.push(name);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const joined = segments.join('/');
|
|
89
|
+
return { templated: joined || '/', paramNames };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { templatePath, isDynamicSegment, paramNameFromContext };
|