contract-drift-detection 0.1.0 → 0.1.2
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 +44 -0
- package/README.md +42 -9
- package/dist/cli.js +368 -42
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +56 -15
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## 0.1.2 - 2026-03-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Root command now accepts serve options directly for faster first-run usage.
|
|
10
|
+
- New runtime diagnostics endpoint: `GET /__routes`.
|
|
11
|
+
- Proxy robustness test coverage for auth header forwarding and diagnostics route.
|
|
12
|
+
|
|
13
|
+
### Improved
|
|
14
|
+
|
|
15
|
+
- Startup output now prints a clear banner with mode/spec/db/routes information.
|
|
16
|
+
- Drift alerts are more prominent and actionable in terminal output.
|
|
17
|
+
- Drift validation now skips non-2xx proxy responses to reduce false/noisy alerts.
|
|
18
|
+
- Proxy forwarding now preserves incoming request headers more faithfully.
|
|
19
|
+
|
|
20
|
+
## 0.1.1 - 2026-03-16
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- New `serve` onboarding modes:
|
|
25
|
+
- `--spec-url <url>` to fetch remote OpenAPI specs
|
|
26
|
+
- `--discover <backend-url>` to auto-discover OpenAPI endpoints
|
|
27
|
+
- New `quickstart` command to generate starter contract and run instantly.
|
|
28
|
+
- `init --template rest-crud|none` for template-driven setup.
|
|
29
|
+
- Local cache for remote/discovered specs under `.cdd/`.
|
|
30
|
+
- New onboarding tests covering local, remote, and discovery-based spec resolution.
|
|
31
|
+
|
|
32
|
+
### Improved
|
|
33
|
+
|
|
34
|
+
- README now includes no-friction first-run flows for users without an existing local spec file.
|
|
35
|
+
|
|
36
|
+
## 0.1.0 - 2026-03-16
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Initial public release with stateful OpenAPI mock server.
|
|
41
|
+
- CRUD route generation from OpenAPI 3.x documents.
|
|
42
|
+
- `x-mock-state` workflow action support.
|
|
43
|
+
- Proxy mode with contract drift detection.
|
|
44
|
+
- Browser CORS support and frontend/backend demo harness.
|
package/README.md
CHANGED
|
@@ -39,9 +39,11 @@ This project combines both and adds a third layer: drift detection against a liv
|
|
|
39
39
|
### Use directly with `npx` (no install)
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
npx contract-drift-detection@latest
|
|
42
|
+
npx contract-drift-detection@latest --spec ./openapi.yaml --port 4010
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
(`serve` subcommand is optional; root command accepts serve options.)
|
|
46
|
+
|
|
45
47
|
### Install globally
|
|
46
48
|
|
|
47
49
|
```bash
|
|
@@ -64,23 +66,41 @@ npm run dev
|
|
|
64
66
|
|
|
65
67
|
## 30-second quick start
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
No spec file yet? Start instantly:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx contract-drift-detection@latest quickstart
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Already have an OpenAPI file? Start with local spec:
|
|
68
76
|
|
|
69
77
|
```bash
|
|
70
|
-
npx
|
|
78
|
+
npx contract-drift-detection@latest serve --spec ./openapi.yaml --port 4010
|
|
71
79
|
```
|
|
72
80
|
|
|
73
|
-
Create a starter config file in
|
|
81
|
+
Create a starter config + starter OpenAPI file in current directory:
|
|
74
82
|
|
|
75
83
|
```bash
|
|
76
|
-
npx
|
|
84
|
+
npx contract-drift-detection@latest init
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If your backend already exposes OpenAPI, avoid writing spec manually:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx contract-drift-detection@latest serve --discover http://localhost:8080 --port 4010
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or use direct remote spec URL:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx contract-drift-detection@latest serve --spec-url http://localhost:8080/openapi.json --port 4010
|
|
77
97
|
```
|
|
78
98
|
|
|
79
99
|
Start in drift detection mode against a real backend:
|
|
80
100
|
|
|
81
101
|
```bash
|
|
82
|
-
npx
|
|
83
|
-
--
|
|
102
|
+
npx contract-drift-detection@latest serve \
|
|
103
|
+
--discover http://localhost:8080 \
|
|
84
104
|
--port 4010 \
|
|
85
105
|
--drift-check http://localhost:8080
|
|
86
106
|
```
|
|
@@ -92,12 +112,24 @@ Point your frontend API base URL to `http://localhost:4010`.
|
|
|
92
112
|
## CLI
|
|
93
113
|
|
|
94
114
|
```text
|
|
95
|
-
contract-drift-detection serve --spec <path> [--port 4010] [--host 0.0.0.0] [--db .mock-db.json] [--cors-origin *] [--drift-check <url>] [--fallback-to-mock] [--verbose]
|
|
96
|
-
contract-drift-detection init [--spec openapi.yaml] [--db .mock-db.json] [--port 4010] [--host 0.0.0.0]
|
|
115
|
+
contract-drift-detection serve [--spec <path> | --spec-url <url> | --discover <backend-url>] [--port 4010] [--host 0.0.0.0] [--db .mock-db.json] [--cors-origin *] [--drift-check <url>] [--fallback-to-mock] [--verbose]
|
|
116
|
+
contract-drift-detection init [--spec openapi.yaml] [--template rest-crud|none] [--db .mock-db.json] [--port 4010] [--host 0.0.0.0]
|
|
117
|
+
contract-drift-detection quickstart [--spec openapi.yaml] [--port 4010] [--host 0.0.0.0] [--db .mock-db.json] [--cors-origin *] [--verbose]
|
|
118
|
+
|
|
119
|
+
# Root command (equivalent to serve)
|
|
120
|
+
contract-drift-detection [--spec <path> | --spec-url <url> | --discover <backend-url>] [--port 4010] ...
|
|
97
121
|
```
|
|
98
122
|
|
|
99
123
|
`--cors-origin` defaults to `*` for frictionless browser testing.
|
|
100
124
|
|
|
125
|
+
Spec resolution priority in `serve` is:
|
|
126
|
+
|
|
127
|
+
1. `--spec`
|
|
128
|
+
2. `--spec-url`
|
|
129
|
+
3. `--discover`
|
|
130
|
+
|
|
131
|
+
Remote/discovered specs are cached locally under `.cdd/`.
|
|
132
|
+
|
|
101
133
|
## Recommended workflows
|
|
102
134
|
|
|
103
135
|
### A) Frontend-first (mock only)
|
|
@@ -149,6 +181,7 @@ Values inside `assign` and `set` can reference request input with `{{body.field}
|
|
|
149
181
|
|
|
150
182
|
- `GET /__health` returns a simple health payload.
|
|
151
183
|
- `GET /__spec` returns the dereferenced OpenAPI document currently in memory.
|
|
184
|
+
- `GET /__routes` returns generated route diagnostics (method/path/operation).
|
|
152
185
|
|
|
153
186
|
## Browser/CORS notes
|
|
154
187
|
|
package/dist/cli.js
CHANGED
|
@@ -4,22 +4,257 @@
|
|
|
4
4
|
import process from "process";
|
|
5
5
|
|
|
6
6
|
// src/config.ts
|
|
7
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
8
|
+
import path2 from "path";
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
|
|
11
|
+
// src/onboarding.ts
|
|
7
12
|
import { mkdir, writeFile } from "fs/promises";
|
|
8
13
|
import path from "path";
|
|
9
|
-
|
|
14
|
+
var DISCOVERY_PATHS = [
|
|
15
|
+
"/openapi.json",
|
|
16
|
+
"/openapi.yaml",
|
|
17
|
+
"/swagger.json",
|
|
18
|
+
"/v3/api-docs",
|
|
19
|
+
"/api-docs-json"
|
|
20
|
+
];
|
|
21
|
+
var STARTER_SPEC = `openapi: 3.0.3
|
|
22
|
+
info:
|
|
23
|
+
title: Contract Drift Detection Starter API
|
|
24
|
+
version: 1.0.0
|
|
25
|
+
paths:
|
|
26
|
+
/users:
|
|
27
|
+
get:
|
|
28
|
+
responses:
|
|
29
|
+
'200':
|
|
30
|
+
description: ok
|
|
31
|
+
content:
|
|
32
|
+
application/json:
|
|
33
|
+
schema:
|
|
34
|
+
type: array
|
|
35
|
+
items:
|
|
36
|
+
$ref: '#/components/schemas/User'
|
|
37
|
+
post:
|
|
38
|
+
requestBody:
|
|
39
|
+
required: true
|
|
40
|
+
content:
|
|
41
|
+
application/json:
|
|
42
|
+
schema:
|
|
43
|
+
$ref: '#/components/schemas/UserCreateInput'
|
|
44
|
+
responses:
|
|
45
|
+
'201':
|
|
46
|
+
description: created
|
|
47
|
+
content:
|
|
48
|
+
application/json:
|
|
49
|
+
schema:
|
|
50
|
+
$ref: '#/components/schemas/User'
|
|
51
|
+
/users/{id}:
|
|
52
|
+
get:
|
|
53
|
+
parameters:
|
|
54
|
+
- $ref: '#/components/parameters/UserId'
|
|
55
|
+
responses:
|
|
56
|
+
'200':
|
|
57
|
+
description: ok
|
|
58
|
+
content:
|
|
59
|
+
application/json:
|
|
60
|
+
schema:
|
|
61
|
+
$ref: '#/components/schemas/User'
|
|
62
|
+
patch:
|
|
63
|
+
parameters:
|
|
64
|
+
- $ref: '#/components/parameters/UserId'
|
|
65
|
+
requestBody:
|
|
66
|
+
required: true
|
|
67
|
+
content:
|
|
68
|
+
application/json:
|
|
69
|
+
schema:
|
|
70
|
+
$ref: '#/components/schemas/UserPatchInput'
|
|
71
|
+
responses:
|
|
72
|
+
'200':
|
|
73
|
+
description: updated
|
|
74
|
+
content:
|
|
75
|
+
application/json:
|
|
76
|
+
schema:
|
|
77
|
+
$ref: '#/components/schemas/User'
|
|
78
|
+
delete:
|
|
79
|
+
parameters:
|
|
80
|
+
- $ref: '#/components/parameters/UserId'
|
|
81
|
+
responses:
|
|
82
|
+
'204':
|
|
83
|
+
description: deleted
|
|
84
|
+
/tickets:
|
|
85
|
+
get:
|
|
86
|
+
responses:
|
|
87
|
+
'200':
|
|
88
|
+
description: ok
|
|
89
|
+
content:
|
|
90
|
+
application/json:
|
|
91
|
+
schema:
|
|
92
|
+
type: array
|
|
93
|
+
items:
|
|
94
|
+
$ref: '#/components/schemas/Ticket'
|
|
95
|
+
/tickets/{id}/resolve:
|
|
96
|
+
post:
|
|
97
|
+
parameters:
|
|
98
|
+
- $ref: '#/components/parameters/TicketId'
|
|
99
|
+
x-mock-state:
|
|
100
|
+
action: update
|
|
101
|
+
target: tickets
|
|
102
|
+
find_by: id
|
|
103
|
+
set:
|
|
104
|
+
status: resolved
|
|
105
|
+
responses:
|
|
106
|
+
'200':
|
|
107
|
+
description: ok
|
|
108
|
+
content:
|
|
109
|
+
application/json:
|
|
110
|
+
schema:
|
|
111
|
+
$ref: '#/components/schemas/Ticket'
|
|
112
|
+
components:
|
|
113
|
+
parameters:
|
|
114
|
+
UserId:
|
|
115
|
+
name: id
|
|
116
|
+
in: path
|
|
117
|
+
required: true
|
|
118
|
+
schema:
|
|
119
|
+
type: integer
|
|
120
|
+
TicketId:
|
|
121
|
+
name: id
|
|
122
|
+
in: path
|
|
123
|
+
required: true
|
|
124
|
+
schema:
|
|
125
|
+
type: integer
|
|
126
|
+
schemas:
|
|
127
|
+
User:
|
|
128
|
+
type: object
|
|
129
|
+
required: [id, name, email]
|
|
130
|
+
properties:
|
|
131
|
+
id:
|
|
132
|
+
type: integer
|
|
133
|
+
name:
|
|
134
|
+
type: string
|
|
135
|
+
email:
|
|
136
|
+
type: string
|
|
137
|
+
format: email
|
|
138
|
+
UserCreateInput:
|
|
139
|
+
type: object
|
|
140
|
+
required: [name, email]
|
|
141
|
+
properties:
|
|
142
|
+
name:
|
|
143
|
+
type: string
|
|
144
|
+
email:
|
|
145
|
+
type: string
|
|
146
|
+
format: email
|
|
147
|
+
UserPatchInput:
|
|
148
|
+
type: object
|
|
149
|
+
properties:
|
|
150
|
+
name:
|
|
151
|
+
type: string
|
|
152
|
+
email:
|
|
153
|
+
type: string
|
|
154
|
+
format: email
|
|
155
|
+
Ticket:
|
|
156
|
+
type: object
|
|
157
|
+
required: [id, title, status]
|
|
158
|
+
properties:
|
|
159
|
+
id:
|
|
160
|
+
type: integer
|
|
161
|
+
title:
|
|
162
|
+
type: string
|
|
163
|
+
status:
|
|
164
|
+
type: string
|
|
165
|
+
enum: [open, resolved]
|
|
166
|
+
`;
|
|
167
|
+
function normalizeBaseUrl(value) {
|
|
168
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
169
|
+
}
|
|
170
|
+
function inferSpecExtension(contentType, content) {
|
|
171
|
+
const normalized = contentType.toLowerCase();
|
|
172
|
+
if (normalized.includes("json")) {
|
|
173
|
+
return "json";
|
|
174
|
+
}
|
|
175
|
+
const trimmed = content.trim();
|
|
176
|
+
return trimmed.startsWith("{") || trimmed.startsWith("[") ? "json" : "yaml";
|
|
177
|
+
}
|
|
178
|
+
async function cacheSpecContent(cwd, specContent, extension, filePrefix) {
|
|
179
|
+
const cacheDir = path.join(cwd, ".cdd");
|
|
180
|
+
await mkdir(cacheDir, { recursive: true });
|
|
181
|
+
const filePath = path.join(cacheDir, `${filePrefix}.${extension}`);
|
|
182
|
+
await writeFile(filePath, specContent, "utf8");
|
|
183
|
+
return filePath;
|
|
184
|
+
}
|
|
185
|
+
async function cacheSpecFromUrl(cwd, specUrl) {
|
|
186
|
+
const response = await fetch(specUrl);
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new Error(`Failed to download OpenAPI spec from ${specUrl}: ${response.status} ${response.statusText}`);
|
|
189
|
+
}
|
|
190
|
+
const content = await response.text();
|
|
191
|
+
const extension = inferSpecExtension(response.headers.get("content-type") ?? "", content);
|
|
192
|
+
return cacheSpecContent(cwd, content, extension, "openapi.cached");
|
|
193
|
+
}
|
|
194
|
+
async function discoverSpecUrl(backendBaseUrl) {
|
|
195
|
+
const baseUrl = normalizeBaseUrl(backendBaseUrl);
|
|
196
|
+
for (const candidatePath of DISCOVERY_PATHS) {
|
|
197
|
+
const candidateUrl = `${baseUrl}${candidatePath}`;
|
|
198
|
+
try {
|
|
199
|
+
const response = await fetch(candidateUrl, { method: "GET" });
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const body = await response.text();
|
|
204
|
+
const maybeSpec = body.includes("openapi") || body.includes("swagger");
|
|
205
|
+
if (!maybeSpec) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
return candidateUrl;
|
|
209
|
+
} catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Could not discover an OpenAPI spec under ${baseUrl}. Tried: ${DISCOVERY_PATHS.join(", ")}`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
async function writeStarterSpec(cwd, specPath) {
|
|
218
|
+
const resolvedPath = path.isAbsolute(specPath) ? specPath : path.join(cwd, specPath);
|
|
219
|
+
await mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
220
|
+
await writeFile(resolvedPath, STARTER_SPEC, "utf8");
|
|
221
|
+
return resolvedPath;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/config.ts
|
|
225
|
+
function resolvePathFromCwd(cwd, value) {
|
|
226
|
+
return path2.isAbsolute(value) ? value : path2.join(cwd, value);
|
|
227
|
+
}
|
|
228
|
+
function applyServeOptions(command) {
|
|
229
|
+
return command.option("--spec <path>", "Path to an OpenAPI 3.x file").option("--spec-url <url>", "Remote URL to an OpenAPI 3.x file").option("--discover <backend-url>", "Discover OpenAPI from a backend URL and cache it locally").option("--port <port>", "Port to bind the server", "4010").option("--host <host>", "Host to bind the server", "0.0.0.0").option("--db <path>", "JSON database path", ".mock-db.json").option("--cors-origin <origin>", "CORS origin (default is *)", "*").option("--drift-check <url>", "Forward traffic to a real backend and validate responses").option("--fallback-to-mock", "Fallback to mock responses when proxying fails", false).option("--verbose", "Enable verbose logging", false);
|
|
230
|
+
}
|
|
10
231
|
function createCli() {
|
|
11
232
|
const program = new Command();
|
|
12
233
|
program.name("contract-drift-detection").description("Stateful OpenAPI mock server with contract drift detection").version("0.1.0");
|
|
13
|
-
program
|
|
14
|
-
program.command("
|
|
234
|
+
applyServeOptions(program);
|
|
235
|
+
applyServeOptions(program.command("serve").description("Start the mock engine"));
|
|
236
|
+
program.command("init").description("Create a starter config for the current workspace").option("--spec <path>", "Default OpenAPI path", "openapi.yaml").option("--template <name>", "Template to generate (rest-crud | none)", "rest-crud").option("--db <path>", "Default JSON database path", ".mock-db.json").option("--port <port>", "Default port", "4010").option("--host <host>", "Default host", "0.0.0.0");
|
|
237
|
+
program.command("quickstart").description("Generate a starter OpenAPI file and start the server immediately").option("--spec <path>", "Starter OpenAPI path", "openapi.yaml").option("--port <port>", "Port to bind the server", "4010").option("--host <host>", "Host to bind the server", "0.0.0.0").option("--db <path>", "JSON database path", ".mock-db.json").option("--cors-origin <origin>", "CORS origin (default is *)", "*").option("--verbose", "Enable verbose logging", false);
|
|
15
238
|
return program;
|
|
16
239
|
}
|
|
17
|
-
function
|
|
240
|
+
async function resolveServeConfig(cwd, options) {
|
|
241
|
+
let resolvedSpecPath;
|
|
242
|
+
if (options.spec) {
|
|
243
|
+
resolvedSpecPath = resolvePathFromCwd(cwd, String(options.spec));
|
|
244
|
+
} else if (options.specUrl) {
|
|
245
|
+
resolvedSpecPath = await cacheSpecFromUrl(cwd, String(options.specUrl));
|
|
246
|
+
} else if (options.discover) {
|
|
247
|
+
const discoveredSpecUrl = await discoverSpecUrl(String(options.discover));
|
|
248
|
+
resolvedSpecPath = await cacheSpecFromUrl(cwd, discoveredSpecUrl);
|
|
249
|
+
}
|
|
250
|
+
if (!resolvedSpecPath) {
|
|
251
|
+
throw new Error("Provide one of --spec, --spec-url, or --discover");
|
|
252
|
+
}
|
|
18
253
|
return {
|
|
19
|
-
specPath:
|
|
254
|
+
specPath: resolvedSpecPath,
|
|
20
255
|
port: Number(options.port ?? 4010),
|
|
21
256
|
host: String(options.host ?? "0.0.0.0"),
|
|
22
|
-
dbPath: String(options.db ?? ".mock-db.json"),
|
|
257
|
+
dbPath: resolvePathFromCwd(cwd, String(options.db ?? ".mock-db.json")),
|
|
23
258
|
corsOrigin: String(options.corsOrigin ?? "*"),
|
|
24
259
|
driftCheckTarget: options.driftCheck ? String(options.driftCheck) : void 0,
|
|
25
260
|
fallbackToMockOnProxyError: Boolean(options.fallbackToMock),
|
|
@@ -27,9 +262,12 @@ function toServeConfig(options) {
|
|
|
27
262
|
};
|
|
28
263
|
}
|
|
29
264
|
async function writeStarterConfig(cwd, initConfig) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
265
|
+
if (initConfig.template === "rest-crud") {
|
|
266
|
+
await writeStarterSpec(cwd, initConfig.spec);
|
|
267
|
+
}
|
|
268
|
+
const targetPath = path2.join(cwd, "contract-drift.config.json");
|
|
269
|
+
await mkdir2(cwd, { recursive: true });
|
|
270
|
+
await writeFile2(
|
|
33
271
|
targetPath,
|
|
34
272
|
`${JSON.stringify(
|
|
35
273
|
{
|
|
@@ -48,12 +286,12 @@ async function writeStarterConfig(cwd, initConfig) {
|
|
|
48
286
|
}
|
|
49
287
|
|
|
50
288
|
// src/server.ts
|
|
51
|
-
import
|
|
289
|
+
import path5 from "path";
|
|
52
290
|
import Fastify from "fastify";
|
|
53
291
|
import cors from "@fastify/cors";
|
|
54
292
|
|
|
55
293
|
// src/utils.ts
|
|
56
|
-
import
|
|
294
|
+
import path3 from "path";
|
|
57
295
|
function isSchemaObject(schema) {
|
|
58
296
|
return Boolean(schema) && !("$ref" in schema);
|
|
59
297
|
}
|
|
@@ -73,7 +311,7 @@ function singularize(value) {
|
|
|
73
311
|
return value;
|
|
74
312
|
}
|
|
75
313
|
function resolveFile(baseDir, filePath) {
|
|
76
|
-
return
|
|
314
|
+
return path3.isAbsolute(filePath) ? filePath : path3.join(baseDir, filePath);
|
|
77
315
|
}
|
|
78
316
|
function getOperationId(method, openApiPath, operation) {
|
|
79
317
|
if (operation.operationId) {
|
|
@@ -215,7 +453,7 @@ var DriftDetector = class {
|
|
|
215
453
|
addFormats(this.ajv);
|
|
216
454
|
}
|
|
217
455
|
ajv = new Ajv({ allErrors: true, strict: false });
|
|
218
|
-
validate(method,
|
|
456
|
+
validate(method, path6, statusCode, schema, body) {
|
|
219
457
|
if (!schema || body === void 0 || body === null) {
|
|
220
458
|
return null;
|
|
221
459
|
}
|
|
@@ -226,14 +464,16 @@ var DriftDetector = class {
|
|
|
226
464
|
}
|
|
227
465
|
const issue = {
|
|
228
466
|
method: method.toUpperCase(),
|
|
229
|
-
path:
|
|
467
|
+
path: path6,
|
|
230
468
|
statusCode,
|
|
231
|
-
message: `Drift detected for ${method.toUpperCase()} ${
|
|
469
|
+
message: `Drift detected for ${method.toUpperCase()} ${path6} (${statusCode})`,
|
|
232
470
|
errors: formatErrors(validate.errors)
|
|
233
471
|
};
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
472
|
+
const errorLines = issue.errors.map((entry) => ` \u2022 ${entry}`).join("\n");
|
|
473
|
+
this.logger.error([
|
|
474
|
+
`${pc.bgRed(pc.black(" \u{1F6A8} DRIFT DETECTED "))} ${pc.bold(issue.method)} ${issue.path} (${statusCode})`,
|
|
475
|
+
errorLines
|
|
476
|
+
].join("\n"));
|
|
237
477
|
return issue;
|
|
238
478
|
}
|
|
239
479
|
};
|
|
@@ -383,8 +623,8 @@ async function loadOpenApiDocument(specPath) {
|
|
|
383
623
|
}
|
|
384
624
|
|
|
385
625
|
// src/state-store.ts
|
|
386
|
-
import { mkdir as
|
|
387
|
-
import
|
|
626
|
+
import { mkdir as mkdir3, readFile, writeFile as writeFile3 } from "fs/promises";
|
|
627
|
+
import path4 from "path";
|
|
388
628
|
function normalizeDatabase(input) {
|
|
389
629
|
return {
|
|
390
630
|
collections: input?.collections ?? {},
|
|
@@ -397,7 +637,7 @@ var JsonStateStore = class {
|
|
|
397
637
|
this.filePath = filePath;
|
|
398
638
|
}
|
|
399
639
|
async initialize(seedCollections) {
|
|
400
|
-
await
|
|
640
|
+
await mkdir3(path4.dirname(this.filePath), { recursive: true });
|
|
401
641
|
try {
|
|
402
642
|
const existing = await this.read();
|
|
403
643
|
let changed = false;
|
|
@@ -428,7 +668,7 @@ var JsonStateStore = class {
|
|
|
428
668
|
return normalizeDatabase(JSON.parse(raw));
|
|
429
669
|
}
|
|
430
670
|
async write(database) {
|
|
431
|
-
await
|
|
671
|
+
await writeFile3(this.filePath, `${JSON.stringify(database, null, 2)}
|
|
432
672
|
`, "utf8");
|
|
433
673
|
}
|
|
434
674
|
async withDatabase(updater) {
|
|
@@ -440,8 +680,8 @@ var JsonStateStore = class {
|
|
|
440
680
|
};
|
|
441
681
|
|
|
442
682
|
// src/proxy.ts
|
|
443
|
-
async function proxyRequest(targetBaseUrl,
|
|
444
|
-
const response = await fetch(new URL(
|
|
683
|
+
async function proxyRequest(targetBaseUrl, path6, init) {
|
|
684
|
+
const response = await fetch(new URL(path6, targetBaseUrl), init);
|
|
445
685
|
const contentType = readContentType(response.headers);
|
|
446
686
|
let body;
|
|
447
687
|
let rawBody;
|
|
@@ -463,6 +703,32 @@ async function proxyRequest(targetBaseUrl, path5, init) {
|
|
|
463
703
|
}
|
|
464
704
|
|
|
465
705
|
// src/server.ts
|
|
706
|
+
function toProxyHeaders(inputHeaders) {
|
|
707
|
+
const passthrough = ["host", "content-length", "connection"];
|
|
708
|
+
const headers = {};
|
|
709
|
+
for (const [key, value] of Object.entries(inputHeaders)) {
|
|
710
|
+
if (passthrough.includes(key)) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (value === void 0) {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
headers[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
717
|
+
}
|
|
718
|
+
return headers;
|
|
719
|
+
}
|
|
720
|
+
function toProxyBody(request) {
|
|
721
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
722
|
+
return void 0;
|
|
723
|
+
}
|
|
724
|
+
if (request.body === void 0 || request.body === null) {
|
|
725
|
+
return void 0;
|
|
726
|
+
}
|
|
727
|
+
if (typeof request.body === "string" || request.body instanceof Uint8Array) {
|
|
728
|
+
return request.body;
|
|
729
|
+
}
|
|
730
|
+
return JSON.stringify(request.body);
|
|
731
|
+
}
|
|
466
732
|
function sendResponse(reply, statusCode, body) {
|
|
467
733
|
if (statusCode === 204) {
|
|
468
734
|
return reply.code(204).send();
|
|
@@ -609,25 +875,28 @@ async function handleMockRoute(store, route, request) {
|
|
|
609
875
|
});
|
|
610
876
|
}
|
|
611
877
|
async function handleProxyRoute(config, route, detector, request) {
|
|
612
|
-
const rawBody = request.body ? JSON.stringify(request.body) : void 0;
|
|
613
878
|
const targetBaseUrl = config.driftCheckTarget;
|
|
614
879
|
if (!targetBaseUrl) {
|
|
615
880
|
throw new Error("Proxy target is not configured");
|
|
616
881
|
}
|
|
882
|
+
const headers = toProxyHeaders(request.headers);
|
|
883
|
+
if (!headers["content-type"] && request.body !== void 0) {
|
|
884
|
+
headers["content-type"] = "application/json";
|
|
885
|
+
}
|
|
617
886
|
const result = await proxyRequest(targetBaseUrl, request.url, {
|
|
618
887
|
method: request.method,
|
|
619
|
-
headers
|
|
620
|
-
|
|
621
|
-
},
|
|
622
|
-
body: rawBody
|
|
888
|
+
headers,
|
|
889
|
+
body: toProxyBody(request)
|
|
623
890
|
});
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
891
|
+
if (result.statusCode >= 200 && result.statusCode < 300) {
|
|
892
|
+
detector.validate(
|
|
893
|
+
route.method,
|
|
894
|
+
route.path,
|
|
895
|
+
result.statusCode,
|
|
896
|
+
route.successResponse?.schema,
|
|
897
|
+
result.body
|
|
898
|
+
);
|
|
899
|
+
}
|
|
631
900
|
return {
|
|
632
901
|
statusCode: result.statusCode,
|
|
633
902
|
headers: Object.fromEntries(result.headers.entries()),
|
|
@@ -664,6 +933,16 @@ async function registerRoutes(app, document, config, store) {
|
|
|
664
933
|
}
|
|
665
934
|
});
|
|
666
935
|
}
|
|
936
|
+
app.get(
|
|
937
|
+
"/__routes",
|
|
938
|
+
async () => routes.map((route) => ({
|
|
939
|
+
method: route.method.toUpperCase(),
|
|
940
|
+
path: route.fastifyPath,
|
|
941
|
+
operationId: route.operationId,
|
|
942
|
+
resource: route.resourceName,
|
|
943
|
+
hasDsl: Boolean(route.operation["x-mock-state"])
|
|
944
|
+
}))
|
|
945
|
+
);
|
|
667
946
|
}
|
|
668
947
|
async function createServer(config) {
|
|
669
948
|
const app = Fastify({
|
|
@@ -679,7 +958,7 @@ async function createServer(config) {
|
|
|
679
958
|
});
|
|
680
959
|
const document = await loadOpenApiDocument(config.specPath);
|
|
681
960
|
const seedCollections = inferSeedCollections(document);
|
|
682
|
-
const dbPath = resolveFile(
|
|
961
|
+
const dbPath = resolveFile(path5.dirname(config.specPath), config.dbPath);
|
|
683
962
|
const store = new JsonStateStore(dbPath);
|
|
684
963
|
await store.initialize(seedCollections);
|
|
685
964
|
app.get("/__health", async () => ({ status: "ok" }));
|
|
@@ -689,16 +968,39 @@ async function createServer(config) {
|
|
|
689
968
|
}
|
|
690
969
|
|
|
691
970
|
// src/cli.ts
|
|
971
|
+
function renderStartupBanner(config) {
|
|
972
|
+
const lines = [
|
|
973
|
+
`\u{1F680} Contract Drift Detection running at http://${config.host}:${config.port}`,
|
|
974
|
+
`- Spec: ${config.specPath}`,
|
|
975
|
+
`- DB: ${config.dbPath}`,
|
|
976
|
+
`- Mode: ${config.driftCheckTarget ? `proxy + drift-check (${config.driftCheckTarget})` : "stateful mock"}`,
|
|
977
|
+
"- Health: GET /__health",
|
|
978
|
+
"- Routes: GET /__routes"
|
|
979
|
+
];
|
|
980
|
+
return `${lines.join("\n")}
|
|
981
|
+
`;
|
|
982
|
+
}
|
|
692
983
|
async function main() {
|
|
693
984
|
const cli = createCli();
|
|
694
985
|
const serveCommand = cli.commands.find((command) => command.name() === "serve");
|
|
695
986
|
const initCommand = cli.commands.find((command) => command.name() === "init");
|
|
696
|
-
|
|
697
|
-
|
|
987
|
+
const quickstartCommand = cli.commands.find((command) => command.name() === "quickstart");
|
|
988
|
+
const startServer = async (rawOptions) => {
|
|
989
|
+
const config = await resolveServeConfig(process.cwd(), rawOptions);
|
|
698
990
|
const server = await createServer(config);
|
|
699
991
|
await server.listen({ port: config.port, host: config.host });
|
|
700
|
-
process.stdout.write(
|
|
701
|
-
|
|
992
|
+
process.stdout.write(renderStartupBanner(config));
|
|
993
|
+
};
|
|
994
|
+
serveCommand?.action(async function() {
|
|
995
|
+
await startServer(this.opts());
|
|
996
|
+
});
|
|
997
|
+
cli.action(async () => {
|
|
998
|
+
const options = cli.opts();
|
|
999
|
+
if (!options.spec && !options.specUrl && !options.discover) {
|
|
1000
|
+
cli.outputHelp();
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
await startServer(options);
|
|
702
1004
|
});
|
|
703
1005
|
initCommand?.action(async function() {
|
|
704
1006
|
const options = this.opts();
|
|
@@ -706,11 +1008,35 @@ async function main() {
|
|
|
706
1008
|
spec: String(options.spec),
|
|
707
1009
|
db: String(options.db),
|
|
708
1010
|
host: String(options.host),
|
|
709
|
-
port: Number(options.port)
|
|
1011
|
+
port: Number(options.port),
|
|
1012
|
+
template: options.template === "none" ? "none" : "rest-crud"
|
|
710
1013
|
});
|
|
711
1014
|
process.stdout.write(`Created ${targetPath}
|
|
712
1015
|
`);
|
|
713
1016
|
});
|
|
1017
|
+
quickstartCommand?.action(async function() {
|
|
1018
|
+
const options = this.opts();
|
|
1019
|
+
const cwd = process.cwd();
|
|
1020
|
+
const specPath = String(options.spec ?? "openapi.yaml");
|
|
1021
|
+
await writeStarterConfig(cwd, {
|
|
1022
|
+
spec: specPath,
|
|
1023
|
+
db: String(options.db ?? ".mock-db.json"),
|
|
1024
|
+
host: String(options.host ?? "0.0.0.0"),
|
|
1025
|
+
port: Number(options.port ?? 4010),
|
|
1026
|
+
template: "rest-crud"
|
|
1027
|
+
});
|
|
1028
|
+
const config = await resolveServeConfig(cwd, {
|
|
1029
|
+
spec: specPath,
|
|
1030
|
+
db: String(options.db ?? ".mock-db.json"),
|
|
1031
|
+
host: String(options.host ?? "0.0.0.0"),
|
|
1032
|
+
port: String(options.port ?? 4010),
|
|
1033
|
+
corsOrigin: String(options.corsOrigin ?? "*"),
|
|
1034
|
+
verbose: Boolean(options.verbose)
|
|
1035
|
+
});
|
|
1036
|
+
const server = await createServer(config);
|
|
1037
|
+
await server.listen({ port: config.port, host: config.host });
|
|
1038
|
+
process.stdout.write(renderStartupBanner(config));
|
|
1039
|
+
});
|
|
714
1040
|
await cli.parseAsync(process.argv);
|
|
715
1041
|
}
|
|
716
1042
|
await main().catch((error) => {
|