@icyouo/evt-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/README.md +214 -0
- package/README.zh-CN.md +214 -0
- package/bin/evt.js +8 -0
- package/data/apis/auth.example.yaml +44 -0
- package/data/apis/todo.example.yaml +52 -0
- package/data/flows/login.example.yaml +26 -0
- package/data/flows/smoke.example.yaml +9 -0
- package/data/profiles/local.example.json +29 -0
- package/data/scanner.example.json +40 -0
- package/package.json +35 -0
- package/src/api/liveTester.js +320 -0
- package/src/cache/sessionCache.js +37 -0
- package/src/config/loaders.js +108 -0
- package/src/config/schema.js +107 -0
- package/src/config/serviceScanner.js +426 -0
- package/src/config/validate.js +189 -0
- package/src/config/yaml.js +161 -0
- package/src/flow/inputResolver.js +72 -0
- package/src/flow/runner.js +156 -0
- package/src/http/client.js +46 -0
- package/src/http/requestBuilder.js +218 -0
- package/src/index.js +238 -0
- package/src/template/interpolate.js +65 -0
- package/src/util/args.js +74 -0
- package/src/util/duration.js +25 -0
- package/src/util/json.js +24 -0
- package/src/util/paths.js +38 -0
- package/src/util/redact.js +49 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { buildRequest } = require("../http/requestBuilder");
|
|
4
|
+
const { readCache } = require("../cache/sessionCache");
|
|
5
|
+
const { loadFlow } = require("../config/loaders");
|
|
6
|
+
const { runFlow } = require("../flow/runner");
|
|
7
|
+
const { resolveCliPath } = require("../util/paths");
|
|
8
|
+
const { redact } = require("../util/redact");
|
|
9
|
+
|
|
10
|
+
function isPlainObject(value) {
|
|
11
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function nowCompact() {
|
|
15
|
+
return new Date().toISOString().replace(/[-:.]/g, "").replace("T", "-").slice(0, 15);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ensureCacheDir() {
|
|
19
|
+
const dir = resolveCliPath(".cache");
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function schemaGroups(endpoint) {
|
|
25
|
+
const schema = endpoint.schema || {};
|
|
26
|
+
return [
|
|
27
|
+
["path", schema.path],
|
|
28
|
+
["query", schema.query],
|
|
29
|
+
["body", schema.body]
|
|
30
|
+
].filter(([, group]) => isPlainObject(group));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function schemaEntries(endpoint) {
|
|
34
|
+
return schemaGroups(endpoint).flatMap(([groupName, group]) =>
|
|
35
|
+
Object.entries(group).map(([key, definition]) => ({ groupName, key, definition }))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function accountInput(profile, key) {
|
|
40
|
+
return profile && profile.inputs ? profile.inputs[key] : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function profileTestOption(profile, key) {
|
|
44
|
+
return profile && profile.test ? profile.test[key] : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function valueFromBucket(bucket, groupName, key) {
|
|
48
|
+
if (!isPlainObject(bucket)) return undefined;
|
|
49
|
+
if (isPlainObject(bucket[groupName]) && Object.prototype.hasOwnProperty.call(bucket[groupName], key)) {
|
|
50
|
+
return bucket[groupName][key];
|
|
51
|
+
}
|
|
52
|
+
if (Object.prototype.hasOwnProperty.call(bucket, key)) {
|
|
53
|
+
return bucket[key];
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fixtureValue(endpoint, groupName, key, runtime) {
|
|
59
|
+
const fixtures = runtime.profile.fixtures || {};
|
|
60
|
+
const endpointFixtures = fixtures.endpoints || {};
|
|
61
|
+
const namespaceFixtures = fixtures.namespaces || {};
|
|
62
|
+
|
|
63
|
+
const endpointValue = valueFromBucket(endpointFixtures[endpoint.id], groupName, key);
|
|
64
|
+
if (endpointValue !== undefined) return endpointValue;
|
|
65
|
+
|
|
66
|
+
const namespaceValue = valueFromBucket(namespaceFixtures[endpoint.namespace], groupName, key);
|
|
67
|
+
if (namespaceValue !== undefined) return namespaceValue;
|
|
68
|
+
|
|
69
|
+
return valueFromBucket(fixtures.defaults, groupName, key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function genericTypeSample(type) {
|
|
73
|
+
if (type === "integer") return 1;
|
|
74
|
+
if (type === "number") return 1;
|
|
75
|
+
if (type === "boolean") return false;
|
|
76
|
+
if (type === "array") return [];
|
|
77
|
+
if (type === "object") return {};
|
|
78
|
+
return "test";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sampleValue(endpoint, groupName, key, rawDefinition, runtime) {
|
|
82
|
+
const definition = isPlainObject(rawDefinition) ? rawDefinition : { type: String(rawDefinition) };
|
|
83
|
+
const fixture = fixtureValue(endpoint, groupName, key, runtime);
|
|
84
|
+
if (fixture !== undefined) return fixture;
|
|
85
|
+
if (definition.example !== undefined) return definition.example;
|
|
86
|
+
if (definition.default !== undefined) return definition.default;
|
|
87
|
+
if (Array.isArray(definition.enum) && definition.enum.length > 0) {
|
|
88
|
+
return definition.enum[0];
|
|
89
|
+
}
|
|
90
|
+
return genericTypeSample(definition.type || "string");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sampleValuesForEndpoint(endpoint, runtime) {
|
|
94
|
+
const values = {
|
|
95
|
+
args: {},
|
|
96
|
+
path: {},
|
|
97
|
+
query: {},
|
|
98
|
+
body: {}
|
|
99
|
+
};
|
|
100
|
+
for (const { groupName, key, definition } of schemaEntries(endpoint)) {
|
|
101
|
+
const value = sampleValue(endpoint, groupName, key, definition, runtime);
|
|
102
|
+
values[groupName][key] = value;
|
|
103
|
+
if (!Object.prototype.hasOwnProperty.call(values.args, key)) {
|
|
104
|
+
values.args[key] = value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return values;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sampleArgsForEndpoint(endpoint, runtime) {
|
|
111
|
+
return sampleValuesForEndpoint(endpoint, runtime).args;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function applySetOverrides(group, set) {
|
|
115
|
+
const result = { ...group };
|
|
116
|
+
for (const key of Object.keys(result)) {
|
|
117
|
+
if (Object.prototype.hasOwnProperty.call(set || {}, key)) {
|
|
118
|
+
result[key] = set[key];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function endpointOrder(left, right) {
|
|
125
|
+
if (left.id === "auth.logout") return 1;
|
|
126
|
+
if (right.id === "auth.logout") return -1;
|
|
127
|
+
if (Boolean(left.dangerous) !== Boolean(right.dangerous)) {
|
|
128
|
+
return left.dangerous ? 1 : -1;
|
|
129
|
+
}
|
|
130
|
+
return left.id.localeCompare(right.id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function ensureLogin(runtime, options) {
|
|
134
|
+
if (options.noLogin || runtime.cache.token) {
|
|
135
|
+
return { attempted: false, loggedIn: Boolean(runtime.cache.token) };
|
|
136
|
+
}
|
|
137
|
+
if (!accountInput(runtime.profile, "email") || !accountInput(runtime.profile, "password")) {
|
|
138
|
+
return {
|
|
139
|
+
attempted: false,
|
|
140
|
+
loggedIn: false,
|
|
141
|
+
reason: "profile.inputs.email/profile.inputs.password not configured"
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const flow = loadFlow("login");
|
|
146
|
+
await runFlow(flow, {
|
|
147
|
+
...runtime,
|
|
148
|
+
set: {},
|
|
149
|
+
cliHeaders: {},
|
|
150
|
+
dryRun: false,
|
|
151
|
+
unsafe: false,
|
|
152
|
+
noInteractive: true
|
|
153
|
+
});
|
|
154
|
+
runtime.cache = readCache(runtime.cachePath);
|
|
155
|
+
return { attempted: true, loggedIn: Boolean(runtime.cache.token) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseResponseText(text) {
|
|
159
|
+
try {
|
|
160
|
+
return text ? JSON.parse(text) : null;
|
|
161
|
+
} catch (_) {
|
|
162
|
+
return text;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function classifyResponse(httpStatus, parsed) {
|
|
167
|
+
if (httpStatus < 200 || httpStatus >= 300) return "http_error";
|
|
168
|
+
if (parsed && typeof parsed === "object" && "code" in parsed && parsed.code !== 0) return "api_error";
|
|
169
|
+
return "ok";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchWithTimeout(request, timeoutMs) {
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
175
|
+
try {
|
|
176
|
+
return await fetch(request.url, {
|
|
177
|
+
method: request.method,
|
|
178
|
+
headers: request.headers,
|
|
179
|
+
body: request.encodedBody,
|
|
180
|
+
signal: controller.signal
|
|
181
|
+
});
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function testEndpoint(endpoint, runtime, options) {
|
|
188
|
+
if (endpoint.dangerous && !options.includeDangerous) {
|
|
189
|
+
return {
|
|
190
|
+
endpoint: endpoint.id,
|
|
191
|
+
status: "skipped_dangerous",
|
|
192
|
+
dangerous: true
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (endpoint.auth && !runtime.cache.token) {
|
|
196
|
+
return {
|
|
197
|
+
endpoint: endpoint.id,
|
|
198
|
+
status: "skipped_auth",
|
|
199
|
+
auth: true
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const samples = sampleValuesForEndpoint(endpoint, runtime);
|
|
204
|
+
const args = { ...samples.path, ...samples.args, ...(options.set || {}) };
|
|
205
|
+
const context = {
|
|
206
|
+
profile: runtime.profile,
|
|
207
|
+
cache: runtime.cache,
|
|
208
|
+
args,
|
|
209
|
+
inputs: runtime.profile.inputs || {},
|
|
210
|
+
env: process.env,
|
|
211
|
+
steps: {}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
let request;
|
|
215
|
+
try {
|
|
216
|
+
request = buildRequest(endpoint, {
|
|
217
|
+
...runtime,
|
|
218
|
+
context,
|
|
219
|
+
body: applySetOverrides(samples.body, options.set),
|
|
220
|
+
query: applySetOverrides(samples.query, options.set),
|
|
221
|
+
cliHeaders: options.headers || {},
|
|
222
|
+
baseUrl: options.baseUrl,
|
|
223
|
+
dryRun: false
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return {
|
|
227
|
+
endpoint: endpoint.id,
|
|
228
|
+
status: "build_error",
|
|
229
|
+
error: error.message
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const startedAt = Date.now();
|
|
234
|
+
try {
|
|
235
|
+
const response = await fetchWithTimeout(request, options.timeoutMs);
|
|
236
|
+
const text = await response.text();
|
|
237
|
+
const parsed = parseResponseText(text);
|
|
238
|
+
const status = classifyResponse(response.status, parsed);
|
|
239
|
+
return {
|
|
240
|
+
endpoint: endpoint.id,
|
|
241
|
+
status,
|
|
242
|
+
httpStatus: response.status,
|
|
243
|
+
code: parsed && typeof parsed === "object" ? parsed.code : undefined,
|
|
244
|
+
msg: parsed && typeof parsed === "object" ? parsed.msg : undefined,
|
|
245
|
+
durationMs: Date.now() - startedAt,
|
|
246
|
+
request: redact(request),
|
|
247
|
+
response: redact(parsed)
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return {
|
|
251
|
+
endpoint: endpoint.id,
|
|
252
|
+
status: "network_error",
|
|
253
|
+
durationMs: Date.now() - startedAt,
|
|
254
|
+
error: error.message,
|
|
255
|
+
request: redact(request)
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function summarize(results) {
|
|
261
|
+
return results.reduce((summary, item) => {
|
|
262
|
+
summary[item.status] = (summary[item.status] || 0) + 1;
|
|
263
|
+
return summary;
|
|
264
|
+
}, {});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function reportPath(options) {
|
|
268
|
+
if (options.report) return path.resolve(options.report);
|
|
269
|
+
return path.join(ensureCacheDir(), `api-live-report-${nowCompact()}.json`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function runLiveApiTest(runtime, options = {}) {
|
|
273
|
+
const settings = {
|
|
274
|
+
includeDangerous: Boolean(options.includeDangerous || profileTestOption(runtime.profile, "includeDangerous")),
|
|
275
|
+
noLogin: Boolean(options.noLogin),
|
|
276
|
+
timeoutMs: Number(options.timeoutMs || options.timeout || 15000),
|
|
277
|
+
delayMs: Number(options.delayMs || options.delay || 0),
|
|
278
|
+
only: options.only,
|
|
279
|
+
set: options.set || {},
|
|
280
|
+
headers: options.headers || {},
|
|
281
|
+
baseUrl: options.baseUrl,
|
|
282
|
+
report: options.report
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const login = await ensureLogin(runtime, settings);
|
|
286
|
+
const endpoints = Object.values(runtime.registry)
|
|
287
|
+
.filter((endpoint) => !settings.only || endpoint.id.startsWith(`${settings.only}.`) || endpoint.namespace === settings.only)
|
|
288
|
+
.sort(endpointOrder);
|
|
289
|
+
const results = [];
|
|
290
|
+
|
|
291
|
+
for (const endpoint of endpoints) {
|
|
292
|
+
const result = await testEndpoint(endpoint, runtime, settings);
|
|
293
|
+
results.push(result);
|
|
294
|
+
console.log(`[${results.length}/${endpoints.length}] ${endpoint.id} ${result.status}${result.httpStatus ? ` HTTP ${result.httpStatus}` : ""}`);
|
|
295
|
+
if (settings.delayMs > 0) {
|
|
296
|
+
await new Promise((resolve) => setTimeout(resolve, settings.delayMs));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const report = {
|
|
301
|
+
ok: true,
|
|
302
|
+
profile: runtime.profile.name,
|
|
303
|
+
generatedAt: new Date().toISOString(),
|
|
304
|
+
includeDangerous: settings.includeDangerous,
|
|
305
|
+
login,
|
|
306
|
+
total: endpoints.length,
|
|
307
|
+
summary: summarize(results),
|
|
308
|
+
results
|
|
309
|
+
};
|
|
310
|
+
const file = reportPath(settings);
|
|
311
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
312
|
+
fs.writeFileSync(file, `${JSON.stringify(redact(report), null, 2)}\n`);
|
|
313
|
+
report.reportPath = file;
|
|
314
|
+
return report;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
runLiveApiTest,
|
|
319
|
+
sampleArgsForEndpoint
|
|
320
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { defaultCachePath } = require("../util/paths");
|
|
4
|
+
|
|
5
|
+
function resolveCachePath(cachePath) {
|
|
6
|
+
return cachePath ? path.resolve(cachePath) : defaultCachePath();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readCache(cachePath) {
|
|
10
|
+
const file = resolveCachePath(cachePath);
|
|
11
|
+
if (!fs.existsSync(file)) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeCache(cachePath, value) {
|
|
18
|
+
const file = resolveCachePath(cachePath);
|
|
19
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
20
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clearCache(cachePath) {
|
|
25
|
+
const file = resolveCachePath(cachePath);
|
|
26
|
+
if (fs.existsSync(file)) {
|
|
27
|
+
fs.unlinkSync(file);
|
|
28
|
+
}
|
|
29
|
+
return file;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
resolveCachePath,
|
|
34
|
+
readCache,
|
|
35
|
+
writeCache,
|
|
36
|
+
clearCache
|
|
37
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { parseYaml } = require("./yaml");
|
|
4
|
+
const { resolveCliPath, resolveConfigDir } = require("../util/paths");
|
|
5
|
+
const {
|
|
6
|
+
throwIfInvalid,
|
|
7
|
+
validateApiDocument,
|
|
8
|
+
validateApiRegistry,
|
|
9
|
+
validateFlow,
|
|
10
|
+
validateProfile
|
|
11
|
+
} = require("./validate");
|
|
12
|
+
|
|
13
|
+
function readJson(file) {
|
|
14
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readYaml(file) {
|
|
18
|
+
return parseYaml(fs.readFileSync(file, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listFiles(dir, extensions) {
|
|
22
|
+
if (!fs.existsSync(dir)) return [];
|
|
23
|
+
return fs
|
|
24
|
+
.readdirSync(dir)
|
|
25
|
+
.filter((file) => extensions.includes(path.extname(file)))
|
|
26
|
+
.sort()
|
|
27
|
+
.map((file) => path.join(dir, file));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stripExampleSuffix(name) {
|
|
31
|
+
return name.replace(/\.example$/, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listProfiles() {
|
|
35
|
+
return listFiles(resolveConfigDir("profiles"), [".json"])
|
|
36
|
+
.map((file) => stripExampleSuffix(path.basename(file, ".json")));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadProfile(name = "local") {
|
|
40
|
+
const profilesDir = resolveConfigDir("profiles");
|
|
41
|
+
const file = name.endsWith(".json") || name.includes(path.sep)
|
|
42
|
+
? path.resolve(name)
|
|
43
|
+
: [path.join(profilesDir, `${name}.json`), path.join(profilesDir, `${name}.example.json`)]
|
|
44
|
+
.find((candidate) => fs.existsSync(candidate));
|
|
45
|
+
if (!file || !fs.existsSync(file)) {
|
|
46
|
+
throw new Error(`Profile not found: ${name}`);
|
|
47
|
+
}
|
|
48
|
+
const profile = readJson(file);
|
|
49
|
+
throwIfInvalid(validateProfile(profile, file));
|
|
50
|
+
return profile;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function listApiFiles() {
|
|
54
|
+
return listFiles(resolveConfigDir("apis"), [".yaml", ".yml"]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadApiRegistry() {
|
|
58
|
+
const registry = {};
|
|
59
|
+
for (const file of listApiFiles()) {
|
|
60
|
+
const api = readYaml(file);
|
|
61
|
+
throwIfInvalid(validateApiDocument(api, file));
|
|
62
|
+
if (!api.namespace || !api.endpoints) {
|
|
63
|
+
throw new Error(`Invalid API YAML: ${file}`);
|
|
64
|
+
}
|
|
65
|
+
for (const [name, endpoint] of Object.entries(api.endpoints)) {
|
|
66
|
+
const id = `${api.namespace}.${name}`;
|
|
67
|
+
registry[id] = {
|
|
68
|
+
id,
|
|
69
|
+
namespace: api.namespace,
|
|
70
|
+
name,
|
|
71
|
+
bodyType: "json",
|
|
72
|
+
method: "GET",
|
|
73
|
+
auth: false,
|
|
74
|
+
...endpoint
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throwIfInvalid(validateApiRegistry(registry));
|
|
79
|
+
return registry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function listFlows() {
|
|
83
|
+
return listFiles(resolveConfigDir("flows"), [".yaml", ".yml"])
|
|
84
|
+
.map((file) => stripExampleSuffix(path.basename(file, path.extname(file))));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadFlow(name) {
|
|
88
|
+
const flowsDir = resolveConfigDir("flows");
|
|
89
|
+
const file = name.endsWith(".yaml") || name.endsWith(".yml") || name.includes(path.sep)
|
|
90
|
+
? path.resolve(name)
|
|
91
|
+
: [path.join(flowsDir, `${name}.yaml`), path.join(flowsDir, `${name}.example.yaml`)]
|
|
92
|
+
.find((candidate) => fs.existsSync(candidate));
|
|
93
|
+
if (!file || !fs.existsSync(file)) {
|
|
94
|
+
throw new Error(`Flow not found: ${name}`);
|
|
95
|
+
}
|
|
96
|
+
const flow = readYaml(file);
|
|
97
|
+
throwIfInvalid(validateFlow(flow, file));
|
|
98
|
+
return flow;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
listProfiles,
|
|
103
|
+
loadProfile,
|
|
104
|
+
listApiFiles,
|
|
105
|
+
loadApiRegistry,
|
|
106
|
+
listFlows,
|
|
107
|
+
loadFlow
|
|
108
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const { getPath, interpolate, pruneUndefined } = require("../template/interpolate");
|
|
2
|
+
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function schemaEntries(schemaGroup) {
|
|
8
|
+
return isPlainObject(schemaGroup) ? Object.entries(schemaGroup) : [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function firstDefined(...values) {
|
|
12
|
+
return values.find((value) => value !== undefined && value !== null && value !== "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sourceValue(name, definition, context, explicit) {
|
|
16
|
+
if (explicit && Object.prototype.hasOwnProperty.call(explicit, name)) {
|
|
17
|
+
return explicit[name];
|
|
18
|
+
}
|
|
19
|
+
if (definition.from) {
|
|
20
|
+
const value = getPath(context, definition.from);
|
|
21
|
+
if (value !== undefined && value !== null && value !== "") return value;
|
|
22
|
+
}
|
|
23
|
+
return firstDefined(
|
|
24
|
+
context.args && context.args[name],
|
|
25
|
+
context.inputs && context.inputs[name],
|
|
26
|
+
context.vars && context.vars[name],
|
|
27
|
+
definition.default
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function coerceValue(name, value, definition) {
|
|
32
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
33
|
+
const type = definition.type || "string";
|
|
34
|
+
|
|
35
|
+
if (type === "string") return String(value);
|
|
36
|
+
if (type === "number") {
|
|
37
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
38
|
+
if (!Number.isFinite(number)) throw new Error(`Parameter ${name} must be a number`);
|
|
39
|
+
return number;
|
|
40
|
+
}
|
|
41
|
+
if (type === "integer") {
|
|
42
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
43
|
+
if (!Number.isInteger(number)) throw new Error(`Parameter ${name} must be an integer`);
|
|
44
|
+
return number;
|
|
45
|
+
}
|
|
46
|
+
if (type === "boolean") {
|
|
47
|
+
if (typeof value === "boolean") return value;
|
|
48
|
+
if (value === "true") return true;
|
|
49
|
+
if (value === "false") return false;
|
|
50
|
+
throw new Error(`Parameter ${name} must be a boolean`);
|
|
51
|
+
}
|
|
52
|
+
if (type === "array") {
|
|
53
|
+
return Array.isArray(value) ? value : String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
if (type === "object") {
|
|
56
|
+
if (isPlainObject(value)) return value;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(String(value));
|
|
59
|
+
} catch (_) {
|
|
60
|
+
throw new Error(`Parameter ${name} must be an object`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Unsupported schema type for ${name}: ${type}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateEnum(name, value, definition) {
|
|
67
|
+
if (value === undefined || !Array.isArray(definition.enum)) return;
|
|
68
|
+
const allowed = definition.enum.map(String);
|
|
69
|
+
if (!allowed.includes(String(value))) {
|
|
70
|
+
throw new Error(`Parameter ${name} must be one of: ${allowed.join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function materializeSchemaGroup(schemaGroup, context, explicit = {}, options = {}) {
|
|
75
|
+
const result = {};
|
|
76
|
+
for (const [name, rawDefinition] of schemaEntries(schemaGroup)) {
|
|
77
|
+
const definition = isPlainObject(rawDefinition) ? rawDefinition : { type: String(rawDefinition) };
|
|
78
|
+
let value = sourceValue(name, definition, context, explicit);
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
value = interpolate(value, context);
|
|
81
|
+
}
|
|
82
|
+
if (definition.required && (value === undefined || value === null || value === "")) {
|
|
83
|
+
if (!options.dryRun) {
|
|
84
|
+
throw new Error(`Missing required parameter: ${name}`);
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
value = coerceValue(name, value, definition);
|
|
89
|
+
validateEnum(name, value, definition);
|
|
90
|
+
if (value !== undefined) result[name] = value;
|
|
91
|
+
}
|
|
92
|
+
return pruneUndefined(result);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function materializeEndpointSchema(endpoint, context, explicit, options = {}) {
|
|
96
|
+
const schema = endpoint.schema || {};
|
|
97
|
+
return {
|
|
98
|
+
path: materializeSchemaGroup(schema.path, context, explicit.path || {}, options),
|
|
99
|
+
query: materializeSchemaGroup(schema.query, context, explicit.query || {}, options),
|
|
100
|
+
body: materializeSchemaGroup(schema.body, context, explicit.body || {}, options)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
materializeEndpointSchema,
|
|
106
|
+
materializeSchemaGroup
|
|
107
|
+
};
|