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,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 };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON Schema inference from sample bodies — for HAR-ingest capture (Fix 2).
|
|
5
|
+
*
|
|
6
|
+
* inferSchema(value) → JSON Schema describing one sample
|
|
7
|
+
* mergeSchemas(a, b) → schema that admits both inputs
|
|
8
|
+
*
|
|
9
|
+
* The merger is the interesting bit: across multiple recorded responses
|
|
10
|
+
* for the same endpoint, fields seen in EVERY sample stay `required`;
|
|
11
|
+
* fields seen in only SOME become optional; type conflicts widen
|
|
12
|
+
* conservatively (integer + number → number; otherwise → string).
|
|
13
|
+
*
|
|
14
|
+
* Common string formats (uuid, date-time, email) are detected so the
|
|
15
|
+
* emitted OpenAPI subset is richer than just "type: string".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
const DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
20
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
21
|
+
|
|
22
|
+
function inferSchema(value) {
|
|
23
|
+
if (value === null || value === undefined) return { type: 'null' };
|
|
24
|
+
if (typeof value === 'boolean') return { type: 'boolean' };
|
|
25
|
+
if (typeof value === 'string') {
|
|
26
|
+
if (UUID_RE.test(value)) return { type: 'string', format: 'uuid' };
|
|
27
|
+
if (DATE_TIME_RE.test(value)) return { type: 'string', format: 'date-time' };
|
|
28
|
+
if (EMAIL_RE.test(value)) return { type: 'string', format: 'email' };
|
|
29
|
+
return { type: 'string' };
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === 'number') {
|
|
32
|
+
return Number.isInteger(value) ? { type: 'integer' } : { type: 'number' };
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
if (value.length === 0) return { type: 'array', items: {} };
|
|
36
|
+
let items = inferSchema(value[0]);
|
|
37
|
+
for (let i = 1; i < value.length; i++) {
|
|
38
|
+
items = mergeSchemas(items, inferSchema(value[i]));
|
|
39
|
+
}
|
|
40
|
+
return { type: 'array', items };
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === 'object') {
|
|
43
|
+
const properties = {};
|
|
44
|
+
const required = [];
|
|
45
|
+
for (const [k, v] of Object.entries(value)) {
|
|
46
|
+
properties[k] = inferSchema(v);
|
|
47
|
+
// null values still register the key, but the field is not "required"
|
|
48
|
+
// (it was present-but-null; another sample might omit it entirely).
|
|
49
|
+
if (v !== null && v !== undefined) required.push(k);
|
|
50
|
+
}
|
|
51
|
+
const out = { type: 'object', properties };
|
|
52
|
+
if (required.length > 0) out.required = required;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mergeSchemas(a, b) {
|
|
59
|
+
if (!a || Object.keys(a).length === 0) return b || {};
|
|
60
|
+
if (!b || Object.keys(b).length === 0) return a;
|
|
61
|
+
if (a.type === 'null') return b;
|
|
62
|
+
if (b.type === 'null') return a;
|
|
63
|
+
|
|
64
|
+
if (a.type === b.type) {
|
|
65
|
+
if (a.type === 'object') {
|
|
66
|
+
const out = { type: 'object', properties: {} };
|
|
67
|
+
const allKeys = new Set([
|
|
68
|
+
...Object.keys(a.properties || {}),
|
|
69
|
+
...Object.keys(b.properties || {}),
|
|
70
|
+
]);
|
|
71
|
+
for (const k of allKeys) {
|
|
72
|
+
const aProp = a.properties && a.properties[k];
|
|
73
|
+
const bProp = b.properties && b.properties[k];
|
|
74
|
+
out.properties[k] = aProp && bProp ? mergeSchemas(aProp, bProp) : (aProp || bProp);
|
|
75
|
+
}
|
|
76
|
+
// Required = INTERSECTION (a field is only "always present" if both samples had it).
|
|
77
|
+
const aReq = new Set(a.required || []);
|
|
78
|
+
const bReq = new Set(b.required || []);
|
|
79
|
+
const intersect = [...aReq].filter(k => bReq.has(k));
|
|
80
|
+
if (intersect.length > 0) out.required = intersect;
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
if (a.type === 'array') {
|
|
84
|
+
return { type: 'array', items: mergeSchemas(a.items || {}, b.items || {}) };
|
|
85
|
+
}
|
|
86
|
+
// Same primitive type. Keep `format` only if both samples agreed.
|
|
87
|
+
const out = { type: a.type };
|
|
88
|
+
if (a.format && a.format === b.format) out.format = a.format;
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Type mismatch — widen numerically; otherwise fall back to string.
|
|
93
|
+
if ((a.type === 'integer' && b.type === 'number') ||
|
|
94
|
+
(a.type === 'number' && b.type === 'integer')) {
|
|
95
|
+
return { type: 'number' };
|
|
96
|
+
}
|
|
97
|
+
return { type: 'string' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { inferSchema, mergeSchemas };
|
|
@@ -157,6 +157,25 @@ function applyBdctDefaults(opts, command, { cwd = process.cwd() } = {}) {
|
|
|
157
157
|
opts[f] = (f === 'spec' || f === 'contract') ? resolvePath(def) : def;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// Placeholder check — `<replace-me>` is the marker `specshield init` writes
|
|
161
|
+
// for fields the user skipped during the wizard. Letting it flow through
|
|
162
|
+
// would send the literal string to the backend, producing confusing 4xx
|
|
163
|
+
// errors. Refuse with a message that points at the file and field.
|
|
164
|
+
const placeholders = FIELDS
|
|
165
|
+
.filter(f => opts[f] === '<replace-me>')
|
|
166
|
+
.map(f => '--' + f.replace(/[A-Z]/g, m => '-' + m.toLowerCase()));
|
|
167
|
+
if (placeholders.length > 0) {
|
|
168
|
+
const where = cfg._file ? cfg._file : '(no config file found)';
|
|
169
|
+
const err = new Error(
|
|
170
|
+
`The following value${placeholders.length === 1 ? ' is' : 's are'} still set to ` +
|
|
171
|
+
`the "<replace-me>" placeholder written by \`specshield init\`: ` +
|
|
172
|
+
placeholders.join(', ') + '\n' +
|
|
173
|
+
`Edit ${where} (or pass real values as CLI flags) before re-running.`);
|
|
174
|
+
err.code = 'UNRESOLVED_PLACEHOLDER';
|
|
175
|
+
err.placeholders = placeholders;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
// Required-field check.
|
|
161
180
|
const required = REQUIRED_FIELDS[command] || [];
|
|
162
181
|
const missing = required.filter(k => !opts[k]);
|