specdrift-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/bin/specdrift.js +145 -0
- package/package.json +31 -0
- package/src/diff.js +117 -0
- package/src/endpoints.js +73 -0
- package/src/spec.js +81 -0
- package/test/fixtures/openapi.json +60 -0
- package/test/fixtures/server.js +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Bridges
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# specdrift
|
|
2
|
+
|
|
3
|
+
Point it at a live API and its own OpenAPI spec. It tells you where they've
|
|
4
|
+
drifted apart. Free, zero-dependency, MIT-licensed, GET-only.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
For every `GET` operation in your OpenAPI 3.x spec, specdrift:
|
|
9
|
+
|
|
10
|
+
1. builds a real request from the spec's own declared parameter
|
|
11
|
+
examples/defaults (path and query params — it never guesses a value that
|
|
12
|
+
isn't in the spec),
|
|
13
|
+
2. calls your live API,
|
|
14
|
+
3. compares the actual response against the spec: status code declared?
|
|
15
|
+
required fields present? field types matching (`string`/`number`/
|
|
16
|
+
`integer`/`boolean`/`array`/`object`)? any undeclared extra fields?
|
|
17
|
+
|
|
18
|
+
...and prints a plain-text report. Nothing is mutated, nothing is stored,
|
|
19
|
+
nothing is sent anywhere except your own API.
|
|
20
|
+
|
|
21
|
+
**Safety note:** specdrift only ever sends `GET` requests. It will not call
|
|
22
|
+
`POST`/`PUT`/`PATCH`/`DELETE` on your API, even if your spec declares them —
|
|
23
|
+
running an unattended tool against arbitrary mutating endpoints on someone's
|
|
24
|
+
live system is a real risk, not a hypothetical one, so v1 deliberately
|
|
25
|
+
doesn't do it.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Not yet published to npm, so for now, clone and run directly:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/MattBridges/specdrift.git
|
|
33
|
+
cd specdrift/cli # if running from this monorepo path, otherwise cli/ is the root
|
|
34
|
+
node bin/specdrift.js path/to/openapi.json https://api.example.com
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
specdrift <spec.json> <baseUrl> [--path <regex>] [--timeout <ms>]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `<spec.json>` — a local path or URL to an OpenAPI 3.x **JSON** document
|
|
42
|
+
(YAML is a known v1 gap — see below, it fails loudly rather than silently
|
|
43
|
+
misparsing).
|
|
44
|
+
- `<baseUrl>` — the live API to check.
|
|
45
|
+
- `--path <regex>` — only check paths matching this regex.
|
|
46
|
+
- `--timeout <ms>` — per-request timeout, default 10000.
|
|
47
|
+
|
|
48
|
+
Exit codes: `0` = no drift found, `1` = drift found in at least one
|
|
49
|
+
endpoint, `2` = specdrift itself couldn't run (bad spec, no checkable
|
|
50
|
+
endpoints, etc.) — matches the convention used across United Front Labs'
|
|
51
|
+
other free CLIs (see
|
|
52
|
+
[d1-migration-guard](https://github.com/MattBridges/d1-migration-guard)) so
|
|
53
|
+
CI usage (`specdrift ... || exit 1`) is predictable across tools.
|
|
54
|
+
|
|
55
|
+
Requires Node.js >= 18 (uses the built-in `fetch`). Zero npm dependencies.
|
|
56
|
+
|
|
57
|
+
## Example
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
$ specdrift openapi.json https://api.example.com
|
|
61
|
+
|
|
62
|
+
specdrift: checking 3 GET endpoint(s) against https://api.example.com
|
|
63
|
+
|
|
64
|
+
OK GET /widgets -- 200 matches spec (declared: 200)
|
|
65
|
+
DRIFT GET /widgets/{id} -- 200 (matched "200")
|
|
66
|
+
$.price: type mismatch -- spec declares "number", actual response has "string"
|
|
67
|
+
info $.sku: field present in actual response but not declared in spec
|
|
68
|
+
SKIP GET /orders/{id} -- path parameter "id" has no example/default value to substitute
|
|
69
|
+
|
|
70
|
+
specdrift: 2 checked, 1 with drift, 1 skipped, 0 request error(s).
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Status: v0.1, verified against real behavior
|
|
74
|
+
|
|
75
|
+
Verified end-to-end with a local fixture HTTP server (`test/fixtures/`) run
|
|
76
|
+
under a real Node process, in two modes: a spec-conformant server (expect
|
|
77
|
+
exit 0, zero findings) and a deliberately drifted one (missing required
|
|
78
|
+
field, extra undeclared field, wrong field type, and an undeclared status
|
|
79
|
+
code — all four correctly detected with the right exit code). Not tested
|
|
80
|
+
against every real-world OpenAPI dialect quirk (e.g. `oneOf`/`anyOf`/
|
|
81
|
+
`allOf`, `nullable`, deep array recursion beyond the first item) — those are
|
|
82
|
+
either skipped safely (no false "drift") or a documented gap, not silently
|
|
83
|
+
mishandled.
|
|
84
|
+
|
|
85
|
+
## Known v1 limitations (documented, not hidden)
|
|
86
|
+
|
|
87
|
+
- OpenAPI JSON only — no YAML parsing (adding a real YAML parser without a
|
|
88
|
+
dependency is nontrivial; a "known gap" beats a buggy hand-rolled parser).
|
|
89
|
+
- GET-only — no coverage of mutating operations, by design (see Safety note
|
|
90
|
+
above).
|
|
91
|
+
- Path/query parameters need an `example`, `schema.example`,
|
|
92
|
+
`schema.default`, or `schema.enum` in the spec to be tested; parameters
|
|
93
|
+
with none of those are skipped and reported as `SKIP`, not silently
|
|
94
|
+
dropped or guessed.
|
|
95
|
+
- Object/array schema comparison recurses up to 3 levels deep and checks
|
|
96
|
+
only the first item of an array — enough to catch the drift that actually
|
|
97
|
+
happens in practice (renamed/removed/retyped fields), not a full JSON
|
|
98
|
+
Schema validator.
|
|
99
|
+
- `oneOf`/`anyOf`/`allOf`/`nullable` and other advanced JSON Schema
|
|
100
|
+
keywords are not evaluated (no false positives — those fields are simply
|
|
101
|
+
not checked, and this is expected v1 behavior, not a hidden bug).
|
|
102
|
+
|
|
103
|
+
## Why this exists
|
|
104
|
+
|
|
105
|
+
This CLI is a free companion to [SpecDrift](https://mattbridges.github.io/specdrift/),
|
|
106
|
+
a hosted contract-drift monitor concept (continuous checks + AI-written
|
|
107
|
+
plain-language change summaries posted to Slack/PR comments). The free CLI
|
|
108
|
+
gives you a real, usable one-shot check today with zero signup; the hosted
|
|
109
|
+
version (still pre-launch, [waitlist here](https://mattbridges.github.io/specdrift/))
|
|
110
|
+
would run this continuously and explain *why* drift matters, not just that
|
|
111
|
+
it happened.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
package/bin/specdrift.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { loadSpec } = require('../src/spec');
|
|
5
|
+
const { listCheckableEndpoints, buildRequest } = require('../src/endpoints');
|
|
6
|
+
const { diffResponse } = require('../src/diff');
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`specdrift -- check a live API against its own OpenAPI spec
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
specdrift <spec.json> <baseUrl> [options]
|
|
13
|
+
|
|
14
|
+
<spec.json> Path or URL to an OpenAPI 3.x JSON document
|
|
15
|
+
<baseUrl> Base URL of the live API to check, e.g. https://api.example.com
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--path <regex> Only check paths matching this regex
|
|
19
|
+
--timeout <ms> Per-request timeout in milliseconds (default 10000)
|
|
20
|
+
-h, --help Show this help
|
|
21
|
+
|
|
22
|
+
specdrift only sends GET requests. It never calls POST/PUT/PATCH/DELETE against
|
|
23
|
+
your live API. Exit codes: 0 = no drift found, 1 = drift found, 2 = could not run.
|
|
24
|
+
|
|
25
|
+
Docs: https://github.com/MattBridges/specdrift`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const args = { _: [] };
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === '-h' || a === '--help') args.help = true;
|
|
33
|
+
else if (a === '--path') args.pathFilter = argv[++i];
|
|
34
|
+
else if (a === '--timeout') args.timeout = Number(argv[++i]);
|
|
35
|
+
else args._.push(a);
|
|
36
|
+
}
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchWithTimeout(url, timeoutMs) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
|
|
45
|
+
const contentType = res.headers.get('content-type') || '';
|
|
46
|
+
let body = null;
|
|
47
|
+
if (contentType.includes('application/json')) {
|
|
48
|
+
try {
|
|
49
|
+
body = await res.json();
|
|
50
|
+
} catch {
|
|
51
|
+
body = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { status: res.status, body };
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
const args = parseArgs(process.argv.slice(2));
|
|
62
|
+
if (args.help || args._.length < 2) {
|
|
63
|
+
printHelp();
|
|
64
|
+
process.exit(args.help ? 0 : 2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const [specArg, baseUrlArg] = args._;
|
|
68
|
+
const timeout = Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 10000;
|
|
69
|
+
const baseUrl = baseUrlArg.replace(/\/+$/, '');
|
|
70
|
+
|
|
71
|
+
let spec;
|
|
72
|
+
try {
|
|
73
|
+
spec = await loadSpec(specArg);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(`specdrift: ${err.message}`);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let endpoints = listCheckableEndpoints(spec);
|
|
80
|
+
if (args.pathFilter) {
|
|
81
|
+
const re = new RegExp(args.pathFilter);
|
|
82
|
+
endpoints = endpoints.filter((e) => re.test(e.pathTemplate));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (endpoints.length === 0) {
|
|
86
|
+
console.error('specdrift: no checkable GET endpoints found (after filtering, if any).');
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let driftCount = 0;
|
|
91
|
+
let checkedCount = 0;
|
|
92
|
+
let skippedCount = 0;
|
|
93
|
+
let errorCount = 0;
|
|
94
|
+
|
|
95
|
+
console.log(`specdrift: checking ${endpoints.length} GET endpoint(s) against ${baseUrl}\n`);
|
|
96
|
+
|
|
97
|
+
for (const endpoint of endpoints) {
|
|
98
|
+
const built = buildRequest(endpoint);
|
|
99
|
+
if (built.skipped) {
|
|
100
|
+
skippedCount++;
|
|
101
|
+
console.log(`SKIP ${endpoint.method} ${endpoint.pathTemplate} -- ${built.skipped}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const url = baseUrl + built.pathStr;
|
|
106
|
+
let response;
|
|
107
|
+
try {
|
|
108
|
+
response = await fetchWithTimeout(url, timeout);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
errorCount++;
|
|
111
|
+
console.log(`ERROR ${endpoint.method} ${endpoint.pathTemplate} -- request failed: ${err.message}`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
checkedCount++;
|
|
116
|
+
const { findings, matchedStatus, declaredStatuses } = diffResponse(spec, endpoint, response.status, response.body);
|
|
117
|
+
const errors = findings.filter((f) => f.level === 'error');
|
|
118
|
+
const infos = findings.filter((f) => f.level === 'info');
|
|
119
|
+
|
|
120
|
+
if (errors.length === 0) {
|
|
121
|
+
console.log(`OK ${endpoint.method} ${endpoint.pathTemplate} -- ${response.status} matches spec (declared: ${declaredStatuses.join(', ')})`);
|
|
122
|
+
} else {
|
|
123
|
+
driftCount++;
|
|
124
|
+
console.log(`DRIFT ${endpoint.method} ${endpoint.pathTemplate} -- ${response.status}${matchedStatus ? ` (matched "${matchedStatus}")` : ''}`);
|
|
125
|
+
for (const f of errors) {
|
|
126
|
+
console.log(` ${f.path}: ${f.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const f of infos) {
|
|
130
|
+
console.log(` info ${f.path}: ${f.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(
|
|
135
|
+
`\nspecdrift: ${checkedCount} checked, ${driftCount} with drift, ${skippedCount} skipped, ${errorCount} request error(s).`
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (errorCount > 0 && checkedCount === 0) process.exit(2);
|
|
139
|
+
process.exit(driftCount > 0 ? 1 : 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
main().catch((err) => {
|
|
143
|
+
console.error(`specdrift: unexpected error -- ${err.stack || err.message}`);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specdrift-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Check a live API against its own OpenAPI spec and report drift: undocumented status codes, missing required fields, and type mismatches.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"specdrift": "./bin/specdrift.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/MattBridges/specdrift.git",
|
|
13
|
+
"directory": "cli"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/MattBridges/specdrift/tree/main/cli",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/MattBridges/specdrift/issues"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"openapi",
|
|
25
|
+
"api",
|
|
26
|
+
"contract-testing",
|
|
27
|
+
"drift-detection",
|
|
28
|
+
"api-monitoring",
|
|
29
|
+
"devtools"
|
|
30
|
+
]
|
|
31
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveSchema } = require('./spec');
|
|
4
|
+
|
|
5
|
+
const JSON_TYPE_OF = (value) => {
|
|
6
|
+
if (value === null) return 'null';
|
|
7
|
+
if (Array.isArray(value)) return 'array';
|
|
8
|
+
return typeof value === 'number' ? 'number' : typeof value;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function typeMatches(declaredType, actualJsType, actualValue) {
|
|
12
|
+
if (declaredType === 'integer') {
|
|
13
|
+
return actualJsType === 'number' && Number.isInteger(actualValue);
|
|
14
|
+
}
|
|
15
|
+
if (declaredType === 'number') return actualJsType === 'number';
|
|
16
|
+
if (declaredType === 'string') return actualJsType === 'string';
|
|
17
|
+
if (declaredType === 'boolean') return actualJsType === 'boolean';
|
|
18
|
+
if (declaredType === 'array') return actualJsType === 'array';
|
|
19
|
+
if (declaredType === 'object') return actualJsType === 'object';
|
|
20
|
+
return true; // unknown/unspecified declared type -- nothing to compare against
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Walks one level of an object schema against an actual value and returns a
|
|
24
|
+
// flat list of drift findings for that node. Recurses into declared object
|
|
25
|
+
// properties; does not attempt full recursive array-item diffing beyond the
|
|
26
|
+
// first element, since that is where nearly all real-world contract drift
|
|
27
|
+
// (renamed/removed/retyped fields) actually shows up, and going further would
|
|
28
|
+
// mostly add noise for a v1 tool.
|
|
29
|
+
function diffValue(spec, schema, value, path, findings, depth = 0) {
|
|
30
|
+
const resolved = resolveSchema(spec, schema);
|
|
31
|
+
if (!resolved || typeof resolved !== 'object' || Object.keys(resolved).length === 0) {
|
|
32
|
+
return; // no usable schema to compare against
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
findings.push({ level: 'error', path, message: 'declared in spec response but missing from actual response' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const declaredType = resolved.type;
|
|
41
|
+
const actualJsType = JSON_TYPE_OF(value);
|
|
42
|
+
if (declaredType && !typeMatches(declaredType, actualJsType, value)) {
|
|
43
|
+
findings.push({
|
|
44
|
+
level: 'error',
|
|
45
|
+
path,
|
|
46
|
+
message: `type mismatch -- spec declares "${declaredType}", actual response has "${actualJsType}"`,
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (declaredType === 'object' || (!declaredType && resolved.properties)) {
|
|
52
|
+
const props = resolved.properties || {};
|
|
53
|
+
const required = resolved.required || [];
|
|
54
|
+
const actualObj = value && typeof value === 'object' ? value : {};
|
|
55
|
+
for (const key of required) {
|
|
56
|
+
if (!(key in actualObj)) {
|
|
57
|
+
findings.push({ level: 'error', path: `${path}.${key}`, message: 'required field missing from actual response' });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (depth < 3) {
|
|
61
|
+
for (const [key, subSchema] of Object.entries(props)) {
|
|
62
|
+
if (key in actualObj) {
|
|
63
|
+
diffValue(spec, subSchema, actualObj[key], `${path}.${key}`, findings, depth + 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (resolved.additionalProperties === false) {
|
|
68
|
+
for (const key of Object.keys(actualObj)) {
|
|
69
|
+
if (!(key in props)) {
|
|
70
|
+
findings.push({
|
|
71
|
+
level: 'error',
|
|
72
|
+
path: `${path}.${key}`,
|
|
73
|
+
message: 'field present in actual response but not declared in spec, and spec forbids additional properties',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
for (const key of Object.keys(actualObj)) {
|
|
79
|
+
if (!(key in props)) {
|
|
80
|
+
findings.push({
|
|
81
|
+
level: 'info',
|
|
82
|
+
path: `${path}.${key}`,
|
|
83
|
+
message: 'field present in actual response but not declared in spec',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} else if (declaredType === 'array' && resolved.items && Array.isArray(value) && value.length > 0) {
|
|
89
|
+
diffValue(spec, resolved.items, value[0], `${path}[0]`, findings, depth + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Compares one live HTTP response against the OpenAPI spec's declared responses
|
|
94
|
+
// for an operation. Returns { findings, matchedStatus, declaredStatuses }.
|
|
95
|
+
function diffResponse(spec, endpoint, actualStatus, actualBody) {
|
|
96
|
+
const findings = [];
|
|
97
|
+
const declaredStatuses = Object.keys(endpoint.responses);
|
|
98
|
+
const key = declaredStatuses.includes(String(actualStatus)) ? String(actualStatus) : 'default';
|
|
99
|
+
const matched = endpoint.responses[key];
|
|
100
|
+
|
|
101
|
+
if (!matched) {
|
|
102
|
+
findings.push({
|
|
103
|
+
level: 'error',
|
|
104
|
+
path: '(status)',
|
|
105
|
+
message: `actual response status ${actualStatus} is not declared in the spec (declared: ${declaredStatuses.join(', ') || 'none'})`,
|
|
106
|
+
});
|
|
107
|
+
return { findings, matchedStatus: null, declaredStatuses };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const jsonContent = matched.content && matched.content['application/json'];
|
|
111
|
+
if (jsonContent && jsonContent.schema) {
|
|
112
|
+
diffValue(spec, jsonContent.schema, actualBody, '$', findings);
|
|
113
|
+
}
|
|
114
|
+
return { findings, matchedStatus: key, declaredStatuses };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { diffResponse };
|
package/src/endpoints.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveRef } = require('./spec');
|
|
4
|
+
|
|
5
|
+
const CHECKABLE_METHODS = ['get'];
|
|
6
|
+
|
|
7
|
+
// Only GET operations are exercised. specdrift never sends POST/PUT/PATCH/DELETE
|
|
8
|
+
// against a live target -- this is a safety check tool, not a load-testing or
|
|
9
|
+
// fuzzing tool, and calling mutating endpoints on someone's real API without
|
|
10
|
+
// consent would be a real-world harm, not a hypothetical one.
|
|
11
|
+
function listCheckableEndpoints(spec) {
|
|
12
|
+
const endpoints = [];
|
|
13
|
+
const paths = spec.paths || {};
|
|
14
|
+
for (const [pathTemplate, pathItem] of Object.entries(paths)) {
|
|
15
|
+
if (!pathItem || typeof pathItem !== 'object') continue;
|
|
16
|
+
const pathLevelParams = pathItem.parameters || [];
|
|
17
|
+
for (const method of CHECKABLE_METHODS) {
|
|
18
|
+
const op = pathItem[method];
|
|
19
|
+
if (!op) continue;
|
|
20
|
+
const params = [...pathLevelParams, ...(op.parameters || [])].map((p) =>
|
|
21
|
+
p.$ref ? resolveRef(spec, p.$ref) : p
|
|
22
|
+
);
|
|
23
|
+
endpoints.push({
|
|
24
|
+
pathTemplate,
|
|
25
|
+
method: method.toUpperCase(),
|
|
26
|
+
operationId: op.operationId || `${method.toUpperCase()} ${pathTemplate}`,
|
|
27
|
+
params,
|
|
28
|
+
responses: op.responses || {},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return endpoints;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Builds a concrete request (path + query string) from an endpoint's declared
|
|
36
|
+
// parameters, using each parameter's `example` or `schema.default`/`schema.example`
|
|
37
|
+
// as the value. Returns { skipped: reason } if a required parameter has no
|
|
38
|
+
// usable example value -- specdrift will not guess (e.g. inventing a fake user ID),
|
|
39
|
+
// since a wrong guess could produce a misleading drift report.
|
|
40
|
+
function buildRequest(endpoint) {
|
|
41
|
+
let pathStr = endpoint.pathTemplate;
|
|
42
|
+
const query = [];
|
|
43
|
+
for (const param of endpoint.params) {
|
|
44
|
+
const value = exampleValueFor(param);
|
|
45
|
+
if (param.in === 'path') {
|
|
46
|
+
if (value === undefined) {
|
|
47
|
+
return { skipped: `path parameter "${param.name}" has no example/default value to substitute` };
|
|
48
|
+
}
|
|
49
|
+
pathStr = pathStr.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
50
|
+
} else if (param.in === 'query') {
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
if (param.required) {
|
|
53
|
+
return { skipped: `required query parameter "${param.name}" has no example/default value` };
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
query.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const search = query.length ? `?${query.join('&')}` : '';
|
|
61
|
+
return { pathStr: pathStr + search };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function exampleValueFor(param) {
|
|
65
|
+
if (param.example !== undefined) return param.example;
|
|
66
|
+
const schema = param.schema || {};
|
|
67
|
+
if (schema.example !== undefined) return schema.example;
|
|
68
|
+
if (schema.default !== undefined) return schema.default;
|
|
69
|
+
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { listCheckableEndpoints, buildRequest };
|
package/src/spec.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
|
|
7
|
+
function fetchText(url) {
|
|
8
|
+
const client = url.startsWith('https:') ? https : http;
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
client
|
|
11
|
+
.get(url, (res) => {
|
|
12
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
13
|
+
fetchText(res.headers.location).then(resolve, reject);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (res.statusCode !== 200) {
|
|
17
|
+
reject(new Error(`GET ${url} -> HTTP ${res.statusCode}`));
|
|
18
|
+
res.resume();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let body = '';
|
|
22
|
+
res.setEncoding('utf8');
|
|
23
|
+
res.on('data', (chunk) => (body += chunk));
|
|
24
|
+
res.on('end', () => resolve(body));
|
|
25
|
+
})
|
|
26
|
+
.on('error', reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadSpec(specPathOrUrl) {
|
|
31
|
+
let raw;
|
|
32
|
+
if (/^https?:\/\//i.test(specPathOrUrl)) {
|
|
33
|
+
raw = await fetchText(specPathOrUrl);
|
|
34
|
+
} else {
|
|
35
|
+
raw = fs.readFileSync(specPathOrUrl, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
let spec;
|
|
38
|
+
try {
|
|
39
|
+
spec = JSON.parse(raw);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Could not parse "${specPathOrUrl}" as JSON. specdrift v0.1 reads OpenAPI JSON specs only ` +
|
|
43
|
+
`(YAML support is a known gap, not silently ignored -- see README). Parse error: ${err.message}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (!spec.openapi || !String(spec.openapi).startsWith('3.')) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`"${specPathOrUrl}" does not look like an OpenAPI 3.x document (missing/unsupported "openapi" field). ` +
|
|
49
|
+
'specdrift v0.1 supports OpenAPI 3.x only.'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return spec;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveRef(spec, ref) {
|
|
56
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
|
|
57
|
+
throw new Error(`Unsupported $ref (only local refs starting with "#/" are supported): ${ref}`);
|
|
58
|
+
}
|
|
59
|
+
const parts = ref.slice(2).split('/');
|
|
60
|
+
let node = spec;
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
if (node == null || !(part in node)) {
|
|
63
|
+
throw new Error(`Could not resolve $ref "${ref}" in spec`);
|
|
64
|
+
}
|
|
65
|
+
node = node[part];
|
|
66
|
+
}
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveSchema(spec, schema, seen = new Set()) {
|
|
71
|
+
if (schema && typeof schema === 'object' && schema.$ref) {
|
|
72
|
+
if (seen.has(schema.$ref)) {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
seen.add(schema.$ref);
|
|
76
|
+
return resolveSchema(spec, resolveRef(spec, schema.$ref), seen);
|
|
77
|
+
}
|
|
78
|
+
return schema;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { loadSpec, resolveRef, resolveSchema, fetchText };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.0.3",
|
|
3
|
+
"info": { "title": "Fixture API", "version": "1.0.0" },
|
|
4
|
+
"paths": {
|
|
5
|
+
"/widgets": {
|
|
6
|
+
"get": {
|
|
7
|
+
"operationId": "listWidgets",
|
|
8
|
+
"responses": {
|
|
9
|
+
"200": {
|
|
10
|
+
"description": "ok",
|
|
11
|
+
"content": {
|
|
12
|
+
"application/json": {
|
|
13
|
+
"schema": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": { "$ref": "#/components/schemas/Widget" }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"/widgets/{id}": {
|
|
24
|
+
"get": {
|
|
25
|
+
"operationId": "getWidget",
|
|
26
|
+
"parameters": [
|
|
27
|
+
{
|
|
28
|
+
"name": "id",
|
|
29
|
+
"in": "path",
|
|
30
|
+
"required": true,
|
|
31
|
+
"schema": { "type": "string", "example": "w1" }
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"responses": {
|
|
35
|
+
"200": {
|
|
36
|
+
"description": "ok",
|
|
37
|
+
"content": {
|
|
38
|
+
"application/json": { "schema": { "$ref": "#/components/schemas/Widget" } }
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"404": { "description": "not found" }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"components": {
|
|
47
|
+
"schemas": {
|
|
48
|
+
"Widget": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"required": ["id", "name", "price"],
|
|
51
|
+
"properties": {
|
|
52
|
+
"id": { "type": "string" },
|
|
53
|
+
"name": { "type": "string" },
|
|
54
|
+
"price": { "type": "number" },
|
|
55
|
+
"inStock": { "type": "boolean" }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Deterministic fixture server used to verify specdrift's drift detection
|
|
4
|
+
// end-to-end before publishing, the same way d1-migration-guard was verified
|
|
5
|
+
// against planted-good and planted-bad fixtures before its repo went public.
|
|
6
|
+
const http = require('http');
|
|
7
|
+
|
|
8
|
+
const mode = process.argv[2] || 'clean';
|
|
9
|
+
const port = Number(process.argv[3] || 4123);
|
|
10
|
+
|
|
11
|
+
function send(res, status, body) {
|
|
12
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
13
|
+
res.end(JSON.stringify(body));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const server = http.createServer((req, res) => {
|
|
17
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
18
|
+
|
|
19
|
+
if (url.pathname === '/widgets') {
|
|
20
|
+
if (mode === 'clean') {
|
|
21
|
+
return send(res, 200, [{ id: 'w1', name: 'Widget One', price: 9.99, inStock: true }]);
|
|
22
|
+
}
|
|
23
|
+
// drift: missing required "price", extra undeclared "sku"
|
|
24
|
+
return send(res, 200, [{ id: 'w1', name: 'Widget One', sku: 'SKU-1' }]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (url.pathname === '/widgets/w1') {
|
|
28
|
+
if (mode === 'clean') {
|
|
29
|
+
return send(res, 200, { id: 'w1', name: 'Widget One', price: 9.99, inStock: true });
|
|
30
|
+
}
|
|
31
|
+
// drift: price is now a string ("9.99") instead of a number, and status is
|
|
32
|
+
// 500 which the spec never declares for this operation
|
|
33
|
+
return send(res, 500, { id: 'w1', name: 'Widget One', price: '9.99' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
send(res, 404, { error: 'not found' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
server.listen(port, () => {
|
|
40
|
+
console.log(`fixture server (${mode}) listening on http://localhost:${port}`);
|
|
41
|
+
});
|