specshield 3.2.2 → 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 +160 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specshield",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.3",
|
|
4
4
|
"description": "CLI for OpenAPI breaking change detection and bi-directional contract verification — with can-i-deploy gating, GitHub PR checks, and a first-run setup wizard.",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"LICENSE"
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
|
+
"@apidevtools/swagger-parser": "^10.1.1",
|
|
58
|
+
"ajv": "^8.20.0",
|
|
59
|
+
"ajv-formats": "^3.0.1",
|
|
57
60
|
"axios": "^1.6.7",
|
|
58
61
|
"chalk": "^4.1.2",
|
|
59
62
|
"commander": "^12.0.0",
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ const logoutCommand = require('./commands/logout');
|
|
|
9
9
|
const bdctCommand = require('./commands/bdct');
|
|
10
10
|
const historyCommand = require('./commands/history');
|
|
11
11
|
const shareCommand = require('./commands/share');
|
|
12
|
+
const whoamiCommand = require('./commands/whoami');
|
|
12
13
|
|
|
13
14
|
const program = new Command();
|
|
14
15
|
|
|
@@ -28,6 +29,7 @@ program.addCommand(logoutCommand);
|
|
|
28
29
|
program.addCommand(bdctCommand);
|
|
29
30
|
program.addCommand(historyCommand);
|
|
30
31
|
program.addCommand(shareCommand);
|
|
32
|
+
program.addCommand(whoamiCommand);
|
|
31
33
|
|
|
32
34
|
program.parseAsync(process.argv).catch((err) => {
|
|
33
35
|
const logger = require('./utils/logger');
|
package/src/commands/bdct.js
CHANGED
|
@@ -689,6 +689,164 @@ const listConsumersCommand = new Command('list-consumers')
|
|
|
689
689
|
}
|
|
690
690
|
});
|
|
691
691
|
|
|
692
|
+
// ─── capture (Fix 2 — turn recorded traffic into a consumer contract) ────────
|
|
693
|
+
// Reads a HAR file (any browser/Cypress/Playwright/k6 can export one) and
|
|
694
|
+
// emits an OpenAPI 3.0 consumer-contract subset describing only the
|
|
695
|
+
// endpoints/fields the consumer actually called/read. Pure local CLI work —
|
|
696
|
+
// no API token required.
|
|
697
|
+
|
|
698
|
+
const captureFromHarCommand = new Command('from-har')
|
|
699
|
+
.description('Generate a consumer OpenAPI contract from a recorded HAR file')
|
|
700
|
+
.requiredOption('--in <path>', 'Input HAR file (HTTP Archive 1.2)')
|
|
701
|
+
.option('--out <path>', 'Output file (default: write to stdout)')
|
|
702
|
+
.option('--base-url <url>', 'Keep only entries matching this URL prefix (e.g. https://api.acme.com or https://api.acme.com/v1)')
|
|
703
|
+
.option('--method <verbs>', 'Comma-separated methods to include (e.g. GET,POST). Default: all')
|
|
704
|
+
.option('--title <title>', 'OpenAPI info.title', 'Captured consumer contract')
|
|
705
|
+
.option('--version <ver>', 'OpenAPI info.version', '0.1.0')
|
|
706
|
+
.option('--format <fmt>', 'Output format: yaml | json', 'yaml')
|
|
707
|
+
.option('--include-non-json', 'Keep entries with non-JSON bodies (default: drop them)')
|
|
708
|
+
.action(async (opts) => {
|
|
709
|
+
const { captureFromHarFile } = require('../core/har');
|
|
710
|
+
const inputPath = path.resolve(opts.in);
|
|
711
|
+
if (!fsExtra.existsSync(inputPath)) {
|
|
712
|
+
logger.error(`HAR file not found: ${inputPath}`);
|
|
713
|
+
process.exit(2);
|
|
714
|
+
}
|
|
715
|
+
const methods = opts.method
|
|
716
|
+
? opts.method.split(',').map(s => s.trim()).filter(Boolean)
|
|
717
|
+
: undefined;
|
|
718
|
+
|
|
719
|
+
let result;
|
|
720
|
+
try {
|
|
721
|
+
result = captureFromHarFile(inputPath, {
|
|
722
|
+
baseUrl: opts.baseUrl,
|
|
723
|
+
methods,
|
|
724
|
+
onlyJson: !opts.includeNonJson,
|
|
725
|
+
title: opts.title,
|
|
726
|
+
version: opts.version,
|
|
727
|
+
format: opts.format,
|
|
728
|
+
});
|
|
729
|
+
} catch (err) {
|
|
730
|
+
logger.error(err.message);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (opts.out) {
|
|
735
|
+
const outPath = path.resolve(opts.out);
|
|
736
|
+
fsExtra.outputFileSync(outPath, result.text);
|
|
737
|
+
process.stderr.write(chalk.green('✔ ') +
|
|
738
|
+
`Wrote ${chalk.cyan(opts.out)} ` +
|
|
739
|
+
chalk.gray(`(${result.summary.endpoints} endpoints, ${result.summary.operations} ops from ${result.summary.recordsKept}/${result.summary.harEntries} entries)`) + '\n');
|
|
740
|
+
} else {
|
|
741
|
+
process.stdout.write(result.text);
|
|
742
|
+
process.stderr.write(chalk.gray(
|
|
743
|
+
`# ${result.summary.endpoints} endpoints, ${result.summary.operations} ops from ${result.summary.recordsKept}/${result.summary.harEntries} HAR entries\n`
|
|
744
|
+
));
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const captureCommand = new Command('capture')
|
|
749
|
+
.description('Capture a consumer OpenAPI contract from observed traffic (currently: HAR ingest)');
|
|
750
|
+
captureCommand.addCommand(captureFromHarCommand);
|
|
751
|
+
|
|
752
|
+
// ─── verify-provider (Fix 3 — spec-vs-production conformance) ─────────────────
|
|
753
|
+
// Fires probes derived from the OpenAPI spec at the running provider and
|
|
754
|
+
// validates that every response body actually matches its documented schema.
|
|
755
|
+
// Pure local CLI work (calls the customer's service directly); no API token.
|
|
756
|
+
// Safe-by-default: only GET/HEAD/OPTIONS unless --include-mutating.
|
|
757
|
+
|
|
758
|
+
const verifyProviderCommand = new Command('verify-provider')
|
|
759
|
+
.description('Check that a running provider service matches its OpenAPI spec')
|
|
760
|
+
.requiredOption('--spec <path>', 'Path to the provider OpenAPI spec (YAML or JSON)')
|
|
761
|
+
.requiredOption('--base-url <url>', 'Base URL of the running provider, e.g. https://staging.payments.acme.com')
|
|
762
|
+
.option('--include-mutating', 'Also probe POST/PUT/PATCH/DELETE (off by default for safety)')
|
|
763
|
+
.option('--path-params <kvList>', 'Resolve path params: name=val,other=val (overrides spec examples)', collectPathParams, {})
|
|
764
|
+
.option('--header <header>', 'Extra request header to send, e.g. "Authorization: Bearer X" (repeatable)', collectHeaders, {})
|
|
765
|
+
.option('--timeout-ms <ms>', 'Per-request timeout in ms', v => parseInt(v, 10), 8000)
|
|
766
|
+
.option('--json', 'Output raw JSON instead of the human report')
|
|
767
|
+
.action(async (opts) => {
|
|
768
|
+
const { verifyProvider } = require('../core/conformance');
|
|
769
|
+
const specPath = path.resolve(opts.spec);
|
|
770
|
+
if (!fsExtra.existsSync(specPath)) {
|
|
771
|
+
logger.error(`Spec file not found: ${specPath}`);
|
|
772
|
+
process.exit(2);
|
|
773
|
+
}
|
|
774
|
+
const spinner = opts.json ? null : ora('Probing provider…').start();
|
|
775
|
+
let report;
|
|
776
|
+
try {
|
|
777
|
+
report = await verifyProvider({
|
|
778
|
+
spec: specPath,
|
|
779
|
+
baseUrl: opts.baseUrl,
|
|
780
|
+
includeMutating: !!opts.includeMutating,
|
|
781
|
+
pathParams: opts.pathParams,
|
|
782
|
+
headers: opts.header,
|
|
783
|
+
timeoutMs: opts.timeoutMs,
|
|
784
|
+
});
|
|
785
|
+
} catch (err) {
|
|
786
|
+
if (spinner) spinner.fail('Conformance run failed');
|
|
787
|
+
logger.error(err.message);
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
if (spinner) spinner.stop();
|
|
791
|
+
|
|
792
|
+
if (opts.json) {
|
|
793
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
794
|
+
process.exit(report.summary.fail + report.summary.error > 0 ? 1 : 0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Human report.
|
|
798
|
+
process.stdout.write('\n' + chalk.bold(' Provider conformance — spec vs ') + chalk.cyan(opts.baseUrl) + '\n');
|
|
799
|
+
process.stdout.write(' ' + '─'.repeat(60) + '\n');
|
|
800
|
+
for (const r of report.results) {
|
|
801
|
+
const label = `${r.method.padEnd(6)} ${r.routePath}`;
|
|
802
|
+
const tag =
|
|
803
|
+
r.status === 'PASS' ? chalk.green(' PASS ') :
|
|
804
|
+
r.status === 'FAIL' ? chalk.red(' FAIL ') :
|
|
805
|
+
r.status === 'ERROR' ? chalk.red(' ERROR ') :
|
|
806
|
+
chalk.yellow(' SKIP ');
|
|
807
|
+
process.stdout.write(` ${tag} ${label}` +
|
|
808
|
+
(r.httpStatus ? chalk.gray(` (${r.httpStatus})`) : '') + '\n');
|
|
809
|
+
if (r.status === 'FAIL' && r.mismatches && r.mismatches.length > 0) {
|
|
810
|
+
for (const m of r.mismatches.slice(0, 5)) {
|
|
811
|
+
process.stdout.write(chalk.gray(` ${m.path || '(root)'}: ${m.message}\n`));
|
|
812
|
+
}
|
|
813
|
+
if (r.mismatches.length > 5) {
|
|
814
|
+
process.stdout.write(chalk.gray(` …and ${r.mismatches.length - 5} more\n`));
|
|
815
|
+
}
|
|
816
|
+
} else if (r.status === 'FAIL' && r.reason) {
|
|
817
|
+
process.stdout.write(chalk.gray(` ${r.reason}\n`));
|
|
818
|
+
} else if (r.status === 'ERROR') {
|
|
819
|
+
process.stdout.write(chalk.gray(` ${r.error}\n`));
|
|
820
|
+
} else if (r.status === 'SKIPPED') {
|
|
821
|
+
process.stdout.write(chalk.gray(` ${r.reason}: ${r.skipReason}\n`));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const s = report.summary;
|
|
825
|
+
process.stdout.write(' ' + '─'.repeat(60) + '\n');
|
|
826
|
+
process.stdout.write(` ${s.pass} pass · ${s.fail} fail · ${s.error} error · ${s.skipped} skip (${s.total} probes)\n\n`);
|
|
827
|
+
process.exit(s.fail + s.error > 0 ? 1 : 0);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// Repeatable --header parser: collects into a map.
|
|
831
|
+
function collectHeaders(val, acc) {
|
|
832
|
+
const idx = val.indexOf(':');
|
|
833
|
+
if (idx < 0) return acc;
|
|
834
|
+
const k = val.slice(0, idx).trim();
|
|
835
|
+
const v = val.slice(idx + 1).trim();
|
|
836
|
+
if (k) acc[k] = v;
|
|
837
|
+
return acc;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// --path-params name=val,name=val parser: merges into the accumulator so the
|
|
841
|
+
// flag can be passed multiple times.
|
|
842
|
+
function collectPathParams(val, acc) {
|
|
843
|
+
for (const pair of String(val).split(',')) {
|
|
844
|
+
const [k, ...rest] = pair.split('=');
|
|
845
|
+
if (k && k.trim()) acc[k.trim()] = rest.join('=').trim();
|
|
846
|
+
}
|
|
847
|
+
return acc;
|
|
848
|
+
}
|
|
849
|
+
|
|
692
850
|
// ─── Parent bdct command ──────────────────────────────────────────────────────
|
|
693
851
|
|
|
694
852
|
const bdct = new Command('bdct')
|
|
@@ -702,5 +860,7 @@ bdct.addCommand(listCommand);
|
|
|
702
860
|
bdct.addCommand(matrixCommand);
|
|
703
861
|
bdct.addCommand(listProvidersCommand);
|
|
704
862
|
bdct.addCommand(listConsumersCommand);
|
|
863
|
+
bdct.addCommand(captureCommand);
|
|
864
|
+
bdct.addCommand(verifyProviderCommand);
|
|
705
865
|
|
|
706
866
|
module.exports = bdct;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `specshield whoami` — print the active customer's identity and the list of
|
|
5
|
+
* organizations they can use as `--org` values. Reuses /auth/validate-api-key
|
|
6
|
+
* (for the customer summary) and /me/orgs (for the org list) — the same two
|
|
7
|
+
* endpoints the init wizard hits.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: the dashboard now surfaces orgKey, but terminal-native users
|
|
10
|
+
* and CI engineers shouldn't have to open a browser to discover what their
|
|
11
|
+
* org_key is. This command makes it a one-line lookup.
|
|
12
|
+
*
|
|
13
|
+
* Resolves the API token the same way every other command does:
|
|
14
|
+
* --api-token flag > $SPECSHIELD_API_KEY > stored ~/.specshield/config.json
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { Command } = require('commander');
|
|
18
|
+
const chalk = require('chalk');
|
|
19
|
+
const axios = require('axios');
|
|
20
|
+
const logger = require('../utils/logger');
|
|
21
|
+
const { getStoredApiKey } = require('../config/localConfig');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SERVER = 'https://specshield.io';
|
|
24
|
+
|
|
25
|
+
const whoami = new Command('whoami');
|
|
26
|
+
|
|
27
|
+
whoami
|
|
28
|
+
.description('Show the signed-in customer and the orgKeys you can use as --org')
|
|
29
|
+
.option('--api-token <key>', 'Override stored / env API token for this call')
|
|
30
|
+
.option('--server <url>', 'Override the SpecShield server URL', DEFAULT_SERVER)
|
|
31
|
+
.option('--json', 'Output machine-readable JSON instead of a table')
|
|
32
|
+
.action(async (opts) => {
|
|
33
|
+
const token = opts.apiToken
|
|
34
|
+
|| process.env.SPECSHIELD_API_KEY
|
|
35
|
+
|| (await getStoredApiKey());
|
|
36
|
+
if (!token) {
|
|
37
|
+
logger.error('Not logged in. Run: specshield login --api-key <KEY>');
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const server = (opts.server || DEFAULT_SERVER).replace(/\/$/, '');
|
|
42
|
+
const headers = { 'X-Api-Key': token, 'X-SpecShield-Client': 'cli' };
|
|
43
|
+
|
|
44
|
+
let me, orgs;
|
|
45
|
+
try {
|
|
46
|
+
const meRes = await axios.post(`${server}/auth/validate-api-key`, {}, { headers, timeout: 8000 });
|
|
47
|
+
if (!meRes.data || !meRes.data.valid) {
|
|
48
|
+
logger.error('API key did not validate against ' + server);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
me = meRes.data;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.error(err.response
|
|
54
|
+
? `Failed to validate token: ${err.response.status} ${JSON.stringify(err.response.data)}`
|
|
55
|
+
: `Could not reach ${server}: ${err.message}`);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const orgsRes = await axios.get(`${server}/me/orgs`, { headers, timeout: 8000 });
|
|
61
|
+
orgs = Array.isArray(orgsRes.data) ? orgsRes.data : (orgsRes.data?.orgs || []);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Org fetch is best-effort; the customer info already printed below
|
|
64
|
+
// is the more important part.
|
|
65
|
+
orgs = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (opts.json) {
|
|
69
|
+
process.stdout.write(JSON.stringify({
|
|
70
|
+
customer: { name: me.name, email: me.email, plan: me.plan, customerId: me.customerId },
|
|
71
|
+
server,
|
|
72
|
+
orgs: orgs.map(o => ({ orgKey: o.orgKey, name: o.name, role: o.myRole || o.role || null })),
|
|
73
|
+
}, null, 2) + '\n');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Human-readable output. Two sections — identity, then orgs table.
|
|
78
|
+
process.stdout.write('\n');
|
|
79
|
+
process.stdout.write(` ${chalk.bold('Logged in as:')} ${me.name || '(no name)'} `);
|
|
80
|
+
if (me.email) process.stdout.write(chalk.gray(`(${me.email})`));
|
|
81
|
+
process.stdout.write(` ${chalk.gray('· plan:')} ${chalk.cyan(me.plan || 'FREE')}\n`);
|
|
82
|
+
process.stdout.write(` ${chalk.gray('Server:')} ${server}\n`);
|
|
83
|
+
process.stdout.write('\n');
|
|
84
|
+
|
|
85
|
+
if (orgs.length === 0) {
|
|
86
|
+
process.stdout.write(chalk.yellow(' You are not a member of any organization yet.\n'));
|
|
87
|
+
process.stdout.write(chalk.gray(` Create one at ${server}/account/team to get an org_key.\n\n`));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
process.stdout.write(` ${chalk.bold('Organizations you can use as --org:')}\n`);
|
|
92
|
+
process.stdout.write(chalk.gray(' ─────────────────────────────────────────────────────\n'));
|
|
93
|
+
const widestKey = Math.max(...orgs.map(o => (o.orgKey || '').length), 8);
|
|
94
|
+
for (const o of orgs) {
|
|
95
|
+
const key = (o.orgKey || '').padEnd(widestKey);
|
|
96
|
+
const role = o.myRole || o.role || '';
|
|
97
|
+
process.stdout.write(
|
|
98
|
+
` ${chalk.cyan(key)} ${o.name || ''}` +
|
|
99
|
+
(role ? ` ${chalk.gray('(' + role + ')')}` : '') + '\n');
|
|
100
|
+
}
|
|
101
|
+
process.stdout.write('\n');
|
|
102
|
+
process.stdout.write(chalk.gray(` Tip: paste an org_key into --org on CLI commands or bdct.org in .specshield.yml\n\n`));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
module.exports = whoami;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-call orchestrator for `specshield bdct verify-provider` — the
|
|
5
|
+
* "spec-vs-production conformance" check (Fix 3 of the BDCT fidelity
|
|
6
|
+
* roadmap).
|
|
7
|
+
*
|
|
8
|
+
* Loads + dereferences an OpenAPI spec, derives a probe list (safe methods
|
|
9
|
+
* only by default), fires the probes at the running provider, validates
|
|
10
|
+
* each response body against the spec's schema for that status, and
|
|
11
|
+
* returns a structured result + summary.
|
|
12
|
+
*
|
|
13
|
+
* Defaults are deliberately conservative — this tool is pointed at REAL
|
|
14
|
+
* customer services (typically staging, sometimes prod), so:
|
|
15
|
+
*
|
|
16
|
+
* - Only GET / HEAD / OPTIONS unless `includeMutating: true`.
|
|
17
|
+
* - Probes whose path params can't be resolved are SKIPPED, not guessed.
|
|
18
|
+
* - Network errors are reported as ERROR results — never thrown — so a
|
|
19
|
+
* single flaky endpoint doesn't kill the whole run.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const axios = require('axios');
|
|
23
|
+
const SwaggerParser = require('@apidevtools/swagger-parser');
|
|
24
|
+
const { buildProbes } = require('./probeBuilder');
|
|
25
|
+
const { runProbes } = require('./runner');
|
|
26
|
+
|
|
27
|
+
async function verifyProvider(opts) {
|
|
28
|
+
const spec = await SwaggerParser.dereference(opts.spec);
|
|
29
|
+
const probes = buildProbes(spec, { includeMutating: !!opts.includeMutating });
|
|
30
|
+
const http = opts.http || defaultHttpAdapter;
|
|
31
|
+
return runProbes(spec, probes, {
|
|
32
|
+
baseUrl: opts.baseUrl,
|
|
33
|
+
pathParams: opts.pathParams,
|
|
34
|
+
headers: opts.headers,
|
|
35
|
+
timeoutMs: opts.timeoutMs,
|
|
36
|
+
http,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Default HTTP adapter (axios). Returns { status, body } and rejects only
|
|
41
|
+
* on connection-level failures — HTTP error statuses come back as data. */
|
|
42
|
+
async function defaultHttpAdapter(method, url, { headers, timeoutMs }) {
|
|
43
|
+
const res = await axios.request({
|
|
44
|
+
method, url, headers, timeout: timeoutMs,
|
|
45
|
+
validateStatus: () => true, // never throw on 4xx/5xx; the validator decides
|
|
46
|
+
responseType: 'json',
|
|
47
|
+
transitional: { silentJSONParsing: true, forcedJSONParsing: true },
|
|
48
|
+
});
|
|
49
|
+
return { status: res.status, body: res.data };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { verifyProvider, defaultHttpAdapter };
|
|
@@ -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 };
|