@testsmith/api-spector 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -6,6 +6,23 @@ const { spawn } = require('child_process')
6
6
 
7
7
  const [, , cmd = 'ui', ...rest] = process.argv
8
8
 
9
+ // ─── Command dispatch ────────────────────────────────────────────────────────
10
+ //
11
+ // Each entry maps a top-level command to the bundled JS file that handles it.
12
+ // `entrypoint: null` is reserved for `ui`, which spawns electron itself rather
13
+ // than a node script. Adding a new CLI surface is one line here + one entry in
14
+ // electron.vite.config.ts.
15
+
16
+ const COMMANDS = {
17
+ ui: { entrypoint: null, runner: 'electron' },
18
+ run: { entrypoint: 'runner.js', runner: 'node' },
19
+ mock: { entrypoint: 'mock.js', runner: 'node' },
20
+ record: { entrypoint: 'record.js', runner: 'node' },
21
+ agents: { entrypoint: 'agents.js', runner: 'node' },
22
+ contract: { entrypoint: 'contract.js',runner: 'node' },
23
+ wsdl: { entrypoint: 'wsdl.js', runner: 'node' },
24
+ }
25
+
9
26
  function printHelp() {
10
27
  console.log('')
11
28
  console.log(' API Spector — local-first API testing tool')
@@ -16,6 +33,7 @@ function printHelp() {
16
33
  console.log(' api-spector mock --workspace <path> Start mock servers from CLI')
17
34
  console.log(' api-spector record --upstream <url> Record API traffic as mock stubs')
18
35
  console.log(' api-spector contract list|run Manage & run pinned contract snapshots')
36
+ console.log(' api-spector wsdl describe|import-* Inspect a WSDL or import as collection/mock')
19
37
  console.log('')
20
38
  console.log(' Options:')
21
39
  console.log(' api-spector agents init <name> Initialize AI agent files')
@@ -34,51 +52,34 @@ function printHelp() {
34
52
  if (cmd === '--help' || cmd === '-h') {
35
53
  printHelp()
36
54
  process.exit(0)
37
- } else if (cmd === 'ui') {
55
+ }
56
+
57
+ const command = COMMANDS[cmd]
58
+ if (!command) {
59
+ console.error(`API Spector — unknown command: "${cmd}"`)
60
+ printHelp()
61
+ process.exit(1)
62
+ }
63
+
64
+ // ui: spawn electron with the app dir
65
+ if (command.runner === 'electron') {
38
66
  const electron = require('electron')
39
67
  const appDir = path.join(__dirname, '..')
68
+ // Forward the user's cwd so the main process can decide whether to open a
69
+ // workspace in this folder, or fall through to the welcome screen. Without
70
+ // this, the app would always auto-load the previously-opened workspace
71
+ // even when launched from an empty/different directory.
40
72
  const proc = spawn(String(electron), [appDir, ...rest], {
41
73
  stdio: 'inherit',
42
- env: process.env,
43
- })
44
- proc.on('close', code => process.exit(code ?? 0))
45
- } else if (cmd === 'run') {
46
- const runnerPath = path.join(__dirname, '..', 'out', 'main', 'runner.js')
47
- const proc = spawn(process.execPath, [runnerPath, ...rest], {
48
- stdio: 'inherit',
49
- env: process.env,
50
- })
51
- proc.on('close', code => process.exit(code ?? 0))
52
- } else if (cmd === 'mock') {
53
- const mockPath = path.join(__dirname, '..', 'out', 'main', 'mock.js')
54
- const proc = spawn(process.execPath, [mockPath, ...rest], {
55
- stdio: 'inherit',
56
- env: process.env,
57
- })
58
- proc.on('close', code => process.exit(code ?? 0))
59
- } else if (cmd === 'record') {
60
- const recordPath = path.join(__dirname, '..', 'out', 'main', 'record.js')
61
- const proc = spawn(process.execPath, [recordPath, ...rest], {
62
- stdio: 'inherit',
63
- env: process.env,
64
- })
65
- proc.on('close', code => process.exit(code ?? 0))
66
- } else if (cmd === 'agents') {
67
- const agentsPath = path.join(__dirname, '..', 'out', 'main', 'agents.js')
68
- const proc = spawn(process.execPath, [agentsPath, ...rest], {
69
- stdio: 'inherit',
70
- env: process.env,
74
+ env: { ...process.env, API_SPECTOR_LAUNCH_CWD: process.cwd() },
71
75
  })
72
76
  proc.on('close', code => process.exit(code ?? 0))
73
- } else if (cmd === 'contract') {
74
- const contractPath = path.join(__dirname, '..', 'out', 'main', 'contract.js')
75
- const proc = spawn(process.execPath, [contractPath, ...rest], {
77
+ } else {
78
+ // node-runnable bundles in out/main/<entrypoint>
79
+ const target = path.join(__dirname, '..', 'out', 'main', command.entrypoint)
80
+ const proc = spawn(process.execPath, [target, ...rest], {
76
81
  stdio: 'inherit',
77
82
  env: process.env,
78
83
  })
79
84
  proc.on('close', code => process.exit(code ?? 0))
80
- } else {
81
- console.error(`API Spector — unknown command: "${cmd}"`)
82
- printHelp()
83
- process.exit(1)
84
85
  }
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const uuid = require("uuid");
4
+ const soapHandler = require("./soap-handler-Cpj-JwyA.js");
5
+ require("https");
6
+ require("http");
7
+ require("@xmldom/xmldom");
8
+ const SOAP_11_CONTENT_TYPE = "text/xml; charset=utf-8";
9
+ const SOAP_12_CONTENT_TYPE = "application/soap+xml; charset=utf-8";
10
+ function contentTypeForSoap(version) {
11
+ return version === "1.2" ? SOAP_12_CONTENT_TYPE : SOAP_11_CONTENT_TYPE;
12
+ }
13
+ function withContentType(headers, value) {
14
+ const idx = headers.findIndex((h) => h.key.toLowerCase() === "content-type");
15
+ const next = { key: "Content-Type", value, enabled: true };
16
+ if (idx === -1) return [...headers, next];
17
+ return headers.map((h, i) => i === idx ? { ...h, value, enabled: true } : h);
18
+ }
19
+ function buildRequestsFromWsdl(parsed) {
20
+ const out = [];
21
+ const byName = /* @__PURE__ */ new Map();
22
+ for (const op of parsed.operations) {
23
+ const existing = byName.get(op.name);
24
+ if (!existing || existing.soapVersion === "1.2" && op.soapVersion === "1.1") byName.set(op.name, op);
25
+ }
26
+ for (const op of byName.values()) {
27
+ const ct = contentTypeForSoap(op.soapVersion);
28
+ const req = {
29
+ id: uuid.v4(),
30
+ name: op.name,
31
+ method: "POST",
32
+ url: op.endpoint ?? "",
33
+ headers: withContentType([], ct),
34
+ params: [],
35
+ auth: { type: "none" },
36
+ protocol: "soap",
37
+ body: {
38
+ mode: "soap",
39
+ soap: {
40
+ wsdlUrl: "",
41
+ operationName: op.name,
42
+ soapAction: op.soapAction ?? "",
43
+ envelope: op.inputTemplate
44
+ }
45
+ },
46
+ description: op.soapAction ? `SOAPAction: ${op.soapAction}` : void 0
47
+ };
48
+ out.push(req);
49
+ }
50
+ return out;
51
+ }
52
+ function buildCollectionFromWsdl(name, parsed) {
53
+ const requests = buildRequestsFromWsdl(parsed);
54
+ const requestMap = {};
55
+ for (const r of requests) requestMap[r.id] = r;
56
+ const collection = {
57
+ version: "1.0",
58
+ id: uuid.v4(),
59
+ name,
60
+ description: `Imported from WSDL — ${parsed.endpoints[0]?.address ?? parsed.targetNamespace}`,
61
+ rootFolder: {
62
+ id: uuid.v4(),
63
+ name: "root",
64
+ description: "",
65
+ folders: [],
66
+ requestIds: requests.map((r) => r.id)
67
+ },
68
+ requests: requestMap
69
+ };
70
+ return { collection, requestIds: requests.map((r) => r.id) };
71
+ }
72
+ function pathFromUrl(url) {
73
+ try {
74
+ return new URL(url).pathname || "/";
75
+ } catch {
76
+ return "/";
77
+ }
78
+ }
79
+ function pickFreePort(existing) {
80
+ let p = 3900;
81
+ while (existing.includes(p)) p++;
82
+ return p;
83
+ }
84
+ function buildMockFromWsdl(name, parsed, existingPorts = []) {
85
+ const opMap = {};
86
+ const seen = /* @__PURE__ */ new Set();
87
+ for (const op of parsed.operations) {
88
+ if (seen.has(op.name)) continue;
89
+ seen.add(op.name);
90
+ opMap[op.name] = soapHandler.buildResponseEnvelope(op.name, parsed.targetNamespace, op.soapVersion);
91
+ }
92
+ const pathsByVersion = /* @__PURE__ */ new Map();
93
+ for (const ep of parsed.endpoints) {
94
+ pathsByVersion.set(pathFromUrl(ep.address), ep.soapVersion);
95
+ }
96
+ if (pathsByVersion.size === 0) pathsByVersion.set("/", "1.1");
97
+ const routes = [];
98
+ for (const [routePath, version] of pathsByVersion.entries()) {
99
+ routes.push({
100
+ id: uuid.v4(),
101
+ method: "POST",
102
+ path: routePath,
103
+ statusCode: 200,
104
+ headers: { "Content-Type": contentTypeForSoap(version) },
105
+ body: "",
106
+ description: `SOAP ${version} — dispatched per operation`,
107
+ script: soapHandler.buildMockDispatchScript(),
108
+ // Externalized so workspace JSON stays compact: the dispatch script reads
109
+ // these via the script-runner's `metadata` context binding instead of
110
+ // baking each envelope as a JS string literal.
111
+ metadata: { soapEnvelopes: opMap, soapVersion: version }
112
+ });
113
+ }
114
+ return {
115
+ version: "1.0",
116
+ id: uuid.v4(),
117
+ name,
118
+ port: pickFreePort(existingPorts),
119
+ routes
120
+ };
121
+ }
122
+ function importWsdl(wsdlText, opts = {}) {
123
+ const parsed = soapHandler.parseWsdl(wsdlText);
124
+ const baseName = opts.name?.trim() || (() => {
125
+ try {
126
+ return new URL(parsed.endpoints[0]?.address ?? "").hostname || "WSDL service";
127
+ } catch {
128
+ return "WSDL service";
129
+ }
130
+ })();
131
+ const { collection } = buildCollectionFromWsdl(baseName, parsed);
132
+ const mock = buildMockFromWsdl(`${baseName} (mock)`, parsed, opts.existingMockPorts ?? []);
133
+ return { parsed, collection, mock };
134
+ }
135
+ function defaultCollectionRelPath(ws, collection) {
136
+ const safe = collection.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "wsdl";
137
+ let i = 0;
138
+ let candidate = `collections/${safe}.json`;
139
+ const existing = new Set(ws.collections);
140
+ while (existing.has(candidate)) {
141
+ i++;
142
+ candidate = `collections/${safe}-${i}.json`;
143
+ }
144
+ return candidate;
145
+ }
146
+ function defaultMockRelPath(mock) {
147
+ return `mocks/${mock.id}.mock.json`;
148
+ }
149
+ exports.buildCollectionFromWsdl = buildCollectionFromWsdl;
150
+ exports.buildMockFromWsdl = buildMockFromWsdl;
151
+ exports.buildRequestsFromWsdl = buildRequestsFromWsdl;
152
+ exports.defaultCollectionRelPath = defaultCollectionRelPath;
153
+ exports.defaultMockRelPath = defaultMockRelPath;
154
+ exports.importWsdl = importWsdl;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const Ajv = require("ajv");
4
+ const ajv = new Ajv({
5
+ allErrors: true,
6
+ strict: false,
7
+ coerceTypes: false,
8
+ allowUnionTypes: true
9
+ });
10
+ function compile(schema) {
11
+ const fn = ajv.compile(schema);
12
+ return function validate(data) {
13
+ if (!fn(data)) {
14
+ const err = (fn.errors ?? []).map((e) => `${e.instancePath || "<root>"} ${e.message ?? "invalid"}`).join("; ");
15
+ throw new Error(`Invalid IPC payload: ${err || "unknown"}`);
16
+ }
17
+ };
18
+ }
19
+ const apiRequestSchema = {
20
+ type: "object",
21
+ properties: {
22
+ id: { type: "string" },
23
+ name: { type: "string" },
24
+ method: { type: "string" },
25
+ url: { type: "string" },
26
+ headers: { type: "array" },
27
+ params: { type: "array" },
28
+ auth: {
29
+ type: "object",
30
+ properties: { type: { type: "string" } },
31
+ required: ["type"],
32
+ additionalProperties: true
33
+ },
34
+ body: {
35
+ type: "object",
36
+ properties: { mode: { type: "string" } },
37
+ required: ["mode"],
38
+ additionalProperties: true
39
+ }
40
+ },
41
+ required: ["id", "name", "method", "url", "headers", "params"],
42
+ additionalProperties: true
43
+ };
44
+ const stringMap = {
45
+ type: "object",
46
+ additionalProperties: { type: "string" }
47
+ };
48
+ const validateSendRequestPayload = compile({
49
+ type: "object",
50
+ properties: {
51
+ request: apiRequestSchema,
52
+ collectionVars: stringMap,
53
+ globals: stringMap,
54
+ piiMaskPatterns: { type: "array", items: { type: "string" } }
55
+ // `environment`, `proxy`, `tls` are accepted as anything — they're
56
+ // covered by the IPC handler's own type-narrow + defaults.
57
+ },
58
+ required: ["request"],
59
+ additionalProperties: true
60
+ });
61
+ const validateContractRunPayload = compile({
62
+ type: "object",
63
+ properties: {
64
+ mode: { type: "string", enum: ["consumer", "provider", "bidirectional"] },
65
+ requests: { type: "array", items: apiRequestSchema },
66
+ envVars: stringMap,
67
+ collectionVars: stringMap,
68
+ specUrl: { type: "string" },
69
+ specPath: { type: "string" },
70
+ specSnapshotRelPath: { type: "string" },
71
+ requestBaseUrl: { type: "string" }
72
+ },
73
+ required: ["mode", "requests"],
74
+ additionalProperties: true
75
+ });
76
+ const validateWsdlFetchUrl = (url) => {
77
+ if (typeof url !== "string" || !url.trim()) throw new Error("Invalid IPC payload: url must be a non-empty string");
78
+ if (!/^https?:\/\//i.test(url)) throw new Error("Invalid IPC payload: url must start with http:// or https://");
79
+ };
80
+ const validateWsdlImport = compile({
81
+ type: "object",
82
+ properties: {
83
+ url: { type: "string" },
84
+ xml: { type: "string" },
85
+ name: { type: "string" },
86
+ extraHeaders: stringMap,
87
+ existingMockPorts: { type: "array", items: { type: "number" } }
88
+ },
89
+ additionalProperties: true
90
+ });
91
+ exports.validateContractRunPayload = validateContractRunPayload;
92
+ exports.validateSendRequestPayload = validateSendRequestPayload;
93
+ exports.validateWsdlFetchUrl = validateWsdlFetchUrl;
94
+ exports.validateWsdlImport = validateWsdlImport;
@@ -169,6 +169,10 @@ async function handleRequest(serverId, req, res, reqStart) {
169
169
  vm__namespace.runInNewContext(route.script, {
170
170
  request: requestCtx,
171
171
  response: responseDraft,
172
+ // Free-form per-route data the importer/user can stash (e.g. SOAP
173
+ // envelopes by operation, lookup tables, fixtures). Frozen so a script
174
+ // can't mutate it across calls.
175
+ metadata: route.metadata ? Object.freeze({ ...route.metadata }) : {},
172
176
  faker: faker2,
173
177
  dayjs,
174
178
  console: { log: (...args) => console.log("[mock-script]", ...args) }
@@ -32,6 +32,7 @@ const jsonpathPlus = require("jsonpath-plus");
32
32
  const xmldom = require("@xmldom/xmldom");
33
33
  const path = require("path");
34
34
  const Ajv = require("ajv");
35
+ const ipcValidate = require("./ipc-validate-CscN4HfG.js");
35
36
  function _interopNamespaceDefault(e) {
36
37
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
37
38
  if (e) {
@@ -642,6 +643,7 @@ async function buildDispatcher(proxy, tls) {
642
643
  }
643
644
  function registerRequestHandler(ipc) {
644
645
  ipc.handle("request:send", async (_e, payload) => {
646
+ ipcValidate.validateSendRequestPayload(payload);
645
647
  const {
646
648
  request: req,
647
649
  environment,