@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,161 @@
|
|
|
1
|
+
function prepareLines(text) {
|
|
2
|
+
return text
|
|
3
|
+
.replace(/\r\n/g, "\n")
|
|
4
|
+
.split("\n")
|
|
5
|
+
.map((raw, number) => ({
|
|
6
|
+
number: number + 1,
|
|
7
|
+
indent: raw.match(/^ */)[0].length,
|
|
8
|
+
text: raw.trimEnd()
|
|
9
|
+
}))
|
|
10
|
+
.filter((line) => {
|
|
11
|
+
const trimmed = line.text.trim();
|
|
12
|
+
return trimmed !== "" && !trimmed.startsWith("#");
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function splitKeyValue(text) {
|
|
17
|
+
const index = text.indexOf(":");
|
|
18
|
+
if (index < 0) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return [text.slice(0, index).trim(), text.slice(index + 1).trim()];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseScalar(raw) {
|
|
25
|
+
const value = raw.trim();
|
|
26
|
+
if (value === "") return "";
|
|
27
|
+
if (value === "null" || value === "~") return null;
|
|
28
|
+
if (value === "true") return true;
|
|
29
|
+
if (value === "false") return false;
|
|
30
|
+
if (value === "{}") return {};
|
|
31
|
+
if (value === "[]") return [];
|
|
32
|
+
if (value === "\"\"") return "";
|
|
33
|
+
if (value === "''") return "";
|
|
34
|
+
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
35
|
+
const body = value.slice(1, -1);
|
|
36
|
+
return value.startsWith("\"") ? body.replace(/\\"/g, "\"") : body.replace(/''/g, "'");
|
|
37
|
+
}
|
|
38
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
|
|
39
|
+
return Number(value);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseBlock(lines, startIndex, indent) {
|
|
45
|
+
let index = startIndex;
|
|
46
|
+
while (index < lines.length && lines[index].indent < indent) {
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
if (index >= lines.length) {
|
|
50
|
+
return { value: {}, index };
|
|
51
|
+
}
|
|
52
|
+
const line = lines[index];
|
|
53
|
+
if (line.indent !== indent) {
|
|
54
|
+
throw new Error(`Invalid YAML indentation at line ${line.number}`);
|
|
55
|
+
}
|
|
56
|
+
return line.text.trimStart().startsWith("- ")
|
|
57
|
+
? parseSequence(lines, index, indent)
|
|
58
|
+
: parseMapping(lines, index, indent);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseMapping(lines, startIndex, indent) {
|
|
62
|
+
const result = {};
|
|
63
|
+
let index = startIndex;
|
|
64
|
+
|
|
65
|
+
while (index < lines.length) {
|
|
66
|
+
const line = lines[index];
|
|
67
|
+
if (line.indent < indent) break;
|
|
68
|
+
if (line.indent > indent) {
|
|
69
|
+
throw new Error(`Unexpected indentation at line ${line.number}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const text = line.text.trim();
|
|
73
|
+
if (text.startsWith("- ")) break;
|
|
74
|
+
|
|
75
|
+
const pair = splitKeyValue(text);
|
|
76
|
+
if (!pair) {
|
|
77
|
+
throw new Error(`Expected key: value at line ${line.number}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [key, rawValue] = pair;
|
|
81
|
+
index += 1;
|
|
82
|
+
if (rawValue === "") {
|
|
83
|
+
if (index < lines.length && lines[index].indent > indent) {
|
|
84
|
+
const child = parseBlock(lines, index, lines[index].indent);
|
|
85
|
+
result[key] = child.value;
|
|
86
|
+
index = child.index;
|
|
87
|
+
} else {
|
|
88
|
+
result[key] = {};
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
result[key] = parseScalar(rawValue);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { value: result, index };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseSequence(lines, startIndex, indent) {
|
|
99
|
+
const result = [];
|
|
100
|
+
let index = startIndex;
|
|
101
|
+
|
|
102
|
+
while (index < lines.length) {
|
|
103
|
+
const line = lines[index];
|
|
104
|
+
if (line.indent < indent) break;
|
|
105
|
+
if (line.indent > indent) {
|
|
106
|
+
throw new Error(`Unexpected indentation at line ${line.number}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const text = line.text.trim();
|
|
110
|
+
if (!text.startsWith("- ")) break;
|
|
111
|
+
const itemText = text.slice(2).trim();
|
|
112
|
+
index += 1;
|
|
113
|
+
|
|
114
|
+
if (itemText === "") {
|
|
115
|
+
const child = parseBlock(lines, index, lines[index].indent);
|
|
116
|
+
result.push(child.value);
|
|
117
|
+
index = child.index;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pair = splitKeyValue(itemText);
|
|
122
|
+
if (!pair) {
|
|
123
|
+
result.push(parseScalar(itemText));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const item = {};
|
|
128
|
+
const [key, rawValue] = pair;
|
|
129
|
+
if (rawValue === "") {
|
|
130
|
+
if (index < lines.length && lines[index].indent > indent) {
|
|
131
|
+
const child = parseBlock(lines, index, lines[index].indent);
|
|
132
|
+
item[key] = child.value;
|
|
133
|
+
index = child.index;
|
|
134
|
+
} else {
|
|
135
|
+
item[key] = {};
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
item[key] = parseScalar(rawValue);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (index < lines.length && lines[index].indent > indent) {
|
|
142
|
+
const child = parseMapping(lines, index, lines[index].indent);
|
|
143
|
+
Object.assign(item, child.value);
|
|
144
|
+
index = child.index;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
result.push(item);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { value: result, index };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseYaml(text) {
|
|
154
|
+
const lines = prepareLines(text);
|
|
155
|
+
if (lines.length === 0) return {};
|
|
156
|
+
return parseBlock(lines, 0, lines[0].indent).value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
parseYaml
|
|
161
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const readline = require("node:readline");
|
|
2
|
+
|
|
3
|
+
function envNameFor(key, input) {
|
|
4
|
+
return input.env || `NEPTUNE_${key.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function promptValue(key, input) {
|
|
8
|
+
const label = input.prompt || key;
|
|
9
|
+
const suffix = input.default !== undefined && input.default !== "" ? ` (${input.default})` : "";
|
|
10
|
+
const question = `${label}${suffix}: `;
|
|
11
|
+
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const rl = readline.createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout,
|
|
16
|
+
terminal: true
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (input.type === "password") {
|
|
20
|
+
rl.stdoutMuted = true;
|
|
21
|
+
rl._writeToOutput = function writeToOutput(stringToWrite) {
|
|
22
|
+
if (!rl.stdoutMuted) {
|
|
23
|
+
rl.output.write(stringToWrite);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
rl.question(question, (answer) => {
|
|
29
|
+
rl.close();
|
|
30
|
+
if (input.type === "password") {
|
|
31
|
+
process.stdout.write("\n");
|
|
32
|
+
}
|
|
33
|
+
resolve(answer === "" && input.default !== undefined ? input.default : answer);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function resolveInputs(flow, options) {
|
|
39
|
+
const definitions = flow.inputs || {};
|
|
40
|
+
const result = {};
|
|
41
|
+
|
|
42
|
+
for (const [key, input] of Object.entries(definitions)) {
|
|
43
|
+
const envName = envNameFor(key, input);
|
|
44
|
+
let value;
|
|
45
|
+
if (Object.prototype.hasOwnProperty.call(options.set || {}, key)) {
|
|
46
|
+
value = options.set[key];
|
|
47
|
+
} else if (process.env[envName] !== undefined) {
|
|
48
|
+
value = process.env[envName];
|
|
49
|
+
} else if (options.cache && options.cache.inputs && options.cache.inputs[key] !== undefined) {
|
|
50
|
+
value = options.cache.inputs[key];
|
|
51
|
+
} else if (options.profile && options.profile.inputs && options.profile.inputs[key] !== undefined) {
|
|
52
|
+
value = options.profile.inputs[key];
|
|
53
|
+
} else if (!options.noInteractive && process.stdin.isTTY) {
|
|
54
|
+
value = await promptValue(key, input);
|
|
55
|
+
} else if (input.default !== undefined) {
|
|
56
|
+
value = input.default;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (input.required && (value === undefined || value === null || value === "")) {
|
|
60
|
+
throw new Error(`Missing required flow input: ${key}`);
|
|
61
|
+
}
|
|
62
|
+
if (value !== undefined) {
|
|
63
|
+
result[key] = value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
resolveInputs
|
|
72
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const { executeEndpoint } = require("../http/client");
|
|
2
|
+
const { getPath, interpolate, pruneUndefined } = require("../template/interpolate");
|
|
3
|
+
const { parseDuration, sleep } = require("../util/duration");
|
|
4
|
+
const { resolveInputs } = require("./inputResolver");
|
|
5
|
+
const { writeCache } = require("../cache/sessionCache");
|
|
6
|
+
|
|
7
|
+
function isTruthyWhen(value) {
|
|
8
|
+
return !(value === undefined || value === null || value === false || value === "" || value === "false");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeExpect(expect) {
|
|
12
|
+
if (!expect) return [];
|
|
13
|
+
return Array.isArray(expect) ? expect : [expect];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertExpectation(stepId, result, expectation) {
|
|
17
|
+
const path = expectation.path;
|
|
18
|
+
if (!path) {
|
|
19
|
+
throw new Error(`Flow step ${stepId} has expect without path`);
|
|
20
|
+
}
|
|
21
|
+
const actual = getPath(result, path);
|
|
22
|
+
if ("equals" in expectation && actual !== expectation.equals) {
|
|
23
|
+
throw new Error(`Flow step ${stepId} expected ${path} to equal ${expectation.equals}, got ${actual}`);
|
|
24
|
+
}
|
|
25
|
+
if (expectation.exists && (actual === undefined || actual === null || actual === "")) {
|
|
26
|
+
throw new Error(`Flow step ${stepId} expected ${path} to exist`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function maybeWait(value, dryRun) {
|
|
31
|
+
const ms = parseDuration(value);
|
|
32
|
+
if (!dryRun) {
|
|
33
|
+
await sleep(ms);
|
|
34
|
+
}
|
|
35
|
+
return ms;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runFlow(flow, options) {
|
|
39
|
+
const inputs = await resolveInputs(flow, options);
|
|
40
|
+
const steps = {};
|
|
41
|
+
const timeline = [];
|
|
42
|
+
const baseContext = {
|
|
43
|
+
profile: options.profile,
|
|
44
|
+
cache: options.cache || {},
|
|
45
|
+
args: options.set || {},
|
|
46
|
+
inputs,
|
|
47
|
+
env: process.env,
|
|
48
|
+
vars: {},
|
|
49
|
+
steps
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (const step of flow.steps || []) {
|
|
53
|
+
if (step.when !== undefined) {
|
|
54
|
+
const shouldRun = interpolate(step.when, baseContext);
|
|
55
|
+
if (!isTruthyWhen(shouldRun)) {
|
|
56
|
+
timeline.push({ id: step.id, skipped: true });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (step.wait !== undefined && !step.call) {
|
|
62
|
+
const ms = await maybeWait(step.wait, options.dryRun);
|
|
63
|
+
timeline.push({ wait: ms, dryRun: Boolean(options.dryRun) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!step.call) {
|
|
68
|
+
throw new Error("Flow step must declare call or wait");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const endpoint = options.registry[step.call];
|
|
72
|
+
if (!endpoint) {
|
|
73
|
+
throw new Error(`Unknown endpoint in flow: ${step.call}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await executeEndpoint(endpoint, {
|
|
77
|
+
...options,
|
|
78
|
+
context: baseContext,
|
|
79
|
+
cache: baseContext.cache,
|
|
80
|
+
body: step.body || {},
|
|
81
|
+
query: step.query || {},
|
|
82
|
+
headers: step.headers || {}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const id = step.id || step.call;
|
|
86
|
+
if (!options.dryRun) {
|
|
87
|
+
for (const expectation of normalizeExpect(step.expect)) {
|
|
88
|
+
assertExpectation(id, result, expectation);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
steps[id] = result;
|
|
92
|
+
if (step.extract && !options.dryRun) {
|
|
93
|
+
for (const [key, sourcePath] of Object.entries(step.extract)) {
|
|
94
|
+
baseContext.vars[key] = getPath(result, sourcePath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (step.cache && !options.dryRun) {
|
|
98
|
+
Object.assign(baseContext.cache, pruneUndefined(interpolate(step.cache, baseContext)));
|
|
99
|
+
}
|
|
100
|
+
timeline.push({
|
|
101
|
+
id,
|
|
102
|
+
call: step.call,
|
|
103
|
+
dryRun: Boolean(options.dryRun),
|
|
104
|
+
status: result.status,
|
|
105
|
+
request: result.request,
|
|
106
|
+
extract: step.extract ? Object.keys(step.extract) : undefined
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (step.afterWait !== undefined) {
|
|
110
|
+
const ms = await maybeWait(step.afterWait, options.dryRun);
|
|
111
|
+
timeline.push({ id, afterWait: ms, dryRun: Boolean(options.dryRun) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let savedCache;
|
|
116
|
+
if (flow.save && flow.save.cache && !options.dryRun) {
|
|
117
|
+
const rendered = pruneUndefined(interpolate(flow.save.cache, baseContext));
|
|
118
|
+
const saveContext = {
|
|
119
|
+
cache: rendered,
|
|
120
|
+
flow: {
|
|
121
|
+
name: flow.name
|
|
122
|
+
},
|
|
123
|
+
steps
|
|
124
|
+
};
|
|
125
|
+
for (const requiredPath of flow.save.required || []) {
|
|
126
|
+
const value = getPath(saveContext, requiredPath);
|
|
127
|
+
if (value === undefined || value === null || value === "") {
|
|
128
|
+
throw new Error(`Flow ${flow.name} did not produce required save value: ${requiredPath}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
savedCache = {
|
|
132
|
+
...(options.cache || {}),
|
|
133
|
+
...rendered,
|
|
134
|
+
updatedAt: new Date().toISOString(),
|
|
135
|
+
lastFlow: {
|
|
136
|
+
name: flow.name,
|
|
137
|
+
finishedAt: new Date().toISOString()
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
writeCache(options.cachePath, savedCache);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
flow: flow.name,
|
|
145
|
+
inputs: Object.keys(inputs),
|
|
146
|
+
vars: Object.keys(baseContext.vars),
|
|
147
|
+
timeline,
|
|
148
|
+
steps,
|
|
149
|
+
savedCache: Boolean(savedCache)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
runFlow,
|
|
155
|
+
isTruthyWhen
|
|
156
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const { buildRequest, toCurl } = require("./requestBuilder");
|
|
2
|
+
|
|
3
|
+
async function executeEndpoint(endpoint, options) {
|
|
4
|
+
if (endpoint.dangerous && !options.dryRun && !options.unsafe) {
|
|
5
|
+
throw new Error(`Endpoint ${endpoint.id} is dangerous. Re-run with --unsafe to execute it.`);
|
|
6
|
+
}
|
|
7
|
+
const request = buildRequest(endpoint, options);
|
|
8
|
+
if (options.dryRun) {
|
|
9
|
+
return {
|
|
10
|
+
dryRun: true,
|
|
11
|
+
request,
|
|
12
|
+
curl: toCurl(request)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const response = await fetch(request.url, {
|
|
17
|
+
method: request.method,
|
|
18
|
+
headers: request.headers,
|
|
19
|
+
body: request.encodedBody
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const text = await response.text();
|
|
23
|
+
let parsed = text;
|
|
24
|
+
try {
|
|
25
|
+
parsed = text ? JSON.parse(text) : null;
|
|
26
|
+
} catch (_) {
|
|
27
|
+
// Keep raw text for non-JSON responses.
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`HTTP ${response.status}: ${typeof parsed === "string" ? parsed : JSON.stringify(parsed)}`);
|
|
32
|
+
}
|
|
33
|
+
if (parsed && typeof parsed === "object" && "code" in parsed && parsed.code !== 0) {
|
|
34
|
+
throw new Error(`API ${parsed.code}: ${parsed.msg || "Business error"}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
status: response.status,
|
|
39
|
+
response: parsed,
|
|
40
|
+
data: parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
executeEndpoint
|
|
46
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { URL, URLSearchParams } = require("node:url");
|
|
4
|
+
const { materializeEndpointSchema } = require("../config/schema");
|
|
5
|
+
const { interpolate, pruneUndefined } = require("../template/interpolate");
|
|
6
|
+
const { redact } = require("../util/redact");
|
|
7
|
+
|
|
8
|
+
function mergeObjects(...values) {
|
|
9
|
+
return Object.assign({}, ...values.filter((value) => value && typeof value === "object"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveBaseUrl(profile, endpoint, override) {
|
|
13
|
+
if (override) return override;
|
|
14
|
+
if (profile.baseUrls) {
|
|
15
|
+
return profile.baseUrls[endpoint.service || "default"] || profile.baseUrls.default || profile.baseUrl;
|
|
16
|
+
}
|
|
17
|
+
return profile.baseUrl;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function joinUrl(baseUrl, endpointPath) {
|
|
21
|
+
if (/^https?:\/\//i.test(endpointPath)) {
|
|
22
|
+
return endpointPath;
|
|
23
|
+
}
|
|
24
|
+
if (!baseUrl) {
|
|
25
|
+
throw new Error("Missing baseUrl");
|
|
26
|
+
}
|
|
27
|
+
return `${baseUrl.replace(/\/+$/, "")}/${String(endpointPath).replace(/^\/+/, "")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderPath(endpointPath, context, dryRun) {
|
|
31
|
+
const source = mergeObjects(context.inputs, context.vars, context.args);
|
|
32
|
+
return String(interpolate(endpointPath, context)).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (token, key) => {
|
|
33
|
+
const value = source[key];
|
|
34
|
+
if (value === undefined || value === null || value === "") {
|
|
35
|
+
if (!dryRun) {
|
|
36
|
+
throw new Error(`Missing path parameter: ${key}`);
|
|
37
|
+
}
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
return encodeURIComponent(String(value));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encodeBody(bodyType, body, options = {}) {
|
|
45
|
+
if (!body || Object.keys(body).length === 0) return undefined;
|
|
46
|
+
if (bodyType === "formUrlEncoded") {
|
|
47
|
+
return new URLSearchParams(body).toString();
|
|
48
|
+
}
|
|
49
|
+
if (bodyType === "json") {
|
|
50
|
+
return JSON.stringify(body);
|
|
51
|
+
}
|
|
52
|
+
if (bodyType === "multipart") {
|
|
53
|
+
if (options.dryRun) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const form = new FormData();
|
|
57
|
+
for (const [key, value] of Object.entries(body)) {
|
|
58
|
+
if (value === undefined || value === null) continue;
|
|
59
|
+
if (key === "file" || key === "filePath") {
|
|
60
|
+
const filePath = String(value);
|
|
61
|
+
const file = fs.readFileSync(filePath);
|
|
62
|
+
const contentType = body.contentType || "application/octet-stream";
|
|
63
|
+
const filename = body.filename || path.basename(filePath);
|
|
64
|
+
form.append("file", new Blob([file], { type: contentType }), filename);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (key === "filename" || key === "contentType") continue;
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
for (const item of value) {
|
|
70
|
+
form.append(key, String(item));
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
form.append(key, String(value));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return form;
|
|
77
|
+
}
|
|
78
|
+
if (bodyType === "raw") {
|
|
79
|
+
return typeof body === "string" ? body : JSON.stringify(body);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Unsupported bodyType: ${bodyType}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function contentTypeFor(bodyType) {
|
|
85
|
+
if (bodyType === "formUrlEncoded") return "application/x-www-form-urlencoded";
|
|
86
|
+
if (bodyType === "json") return "application/json";
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function applyAuth(headers, endpoint, profile, cache, dryRun) {
|
|
91
|
+
if (!endpoint.auth) return;
|
|
92
|
+
const token = cache.token;
|
|
93
|
+
if (!token && !dryRun) {
|
|
94
|
+
throw new Error(`Endpoint ${endpoint.id} requires login token. Run login flow first.`);
|
|
95
|
+
}
|
|
96
|
+
if (!token) return;
|
|
97
|
+
const auth = profile.auth || {};
|
|
98
|
+
const headerName = auth.header || "Authorization";
|
|
99
|
+
headers[headerName] = auth.scheme === "bearer" ? `Bearer ${token}` : token;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildRequest(endpoint, options) {
|
|
103
|
+
const context = options.context;
|
|
104
|
+
const schemaValues = materializeEndpointSchema(endpoint, context, {
|
|
105
|
+
body: options.body || {},
|
|
106
|
+
query: options.query || {}
|
|
107
|
+
}, { dryRun: options.dryRun });
|
|
108
|
+
const schemaContext = {
|
|
109
|
+
...context,
|
|
110
|
+
args: mergeObjects(schemaValues.path, context.args)
|
|
111
|
+
};
|
|
112
|
+
const endpointHeaders = interpolate(endpoint.headers || {}, context);
|
|
113
|
+
const stepHeaders = interpolate(options.headers || {}, context);
|
|
114
|
+
const cliHeaders = options.cliHeaders || {};
|
|
115
|
+
const bodyType = options.bodyType || endpoint.bodyType || "json";
|
|
116
|
+
const method = String(options.method || endpoint.method || "GET").toUpperCase();
|
|
117
|
+
|
|
118
|
+
const renderedEndpointBody = pruneUndefined(interpolate(endpoint.body || {}, context));
|
|
119
|
+
const renderedBodyOverride = pruneUndefined(interpolate(options.body || {}, context));
|
|
120
|
+
const renderedEndpointQuery = pruneUndefined(interpolate(endpoint.query || {}, context));
|
|
121
|
+
const renderedQueryOverride = pruneUndefined(interpolate(options.query || {}, context));
|
|
122
|
+
|
|
123
|
+
const body = pruneUndefined(mergeObjects(schemaValues.body, renderedEndpointBody, renderedBodyOverride));
|
|
124
|
+
const query = pruneUndefined(mergeObjects(schemaValues.query, renderedEndpointQuery, renderedQueryOverride));
|
|
125
|
+
|
|
126
|
+
const headers = pruneUndefined(
|
|
127
|
+
mergeObjects(
|
|
128
|
+
{ Accept: "application/json" },
|
|
129
|
+
interpolate(options.profile.headers || {}, context),
|
|
130
|
+
endpointHeaders,
|
|
131
|
+
stepHeaders,
|
|
132
|
+
cliHeaders
|
|
133
|
+
),
|
|
134
|
+
{ dropEmptyStrings: true }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const contentType = contentTypeFor(bodyType);
|
|
138
|
+
if (contentType && method !== "GET" && method !== "DELETE") {
|
|
139
|
+
headers["Content-Type"] = contentType;
|
|
140
|
+
}
|
|
141
|
+
if (bodyType === "multipart") {
|
|
142
|
+
delete headers["Content-Type"];
|
|
143
|
+
}
|
|
144
|
+
applyAuth(headers, endpoint, options.profile, options.cache || {}, options.dryRun);
|
|
145
|
+
|
|
146
|
+
const baseUrl = resolveBaseUrl(options.profile, endpoint, options.baseUrl);
|
|
147
|
+
const endpointPath = renderPath(endpoint.path, schemaContext, options.dryRun);
|
|
148
|
+
const url = new URL(joinUrl(baseUrl, endpointPath));
|
|
149
|
+
for (const [key, value] of Object.entries(query)) {
|
|
150
|
+
if (value === undefined || value === null) continue;
|
|
151
|
+
url.searchParams.set(key, String(value));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
endpoint: endpoint.id,
|
|
156
|
+
method,
|
|
157
|
+
url: url.toString(),
|
|
158
|
+
headers,
|
|
159
|
+
bodyType,
|
|
160
|
+
body,
|
|
161
|
+
encodedBody: method === "GET" ? undefined : encodeBody(bodyType, body, { dryRun: options.dryRun })
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function encodeCurlBody(request) {
|
|
166
|
+
const safeBody = redact(request.body || {});
|
|
167
|
+
if (request.bodyType === "formUrlEncoded") {
|
|
168
|
+
return new URLSearchParams(safeBody).toString();
|
|
169
|
+
}
|
|
170
|
+
if (request.bodyType === "json") {
|
|
171
|
+
return JSON.stringify(safeBody);
|
|
172
|
+
}
|
|
173
|
+
if (typeof safeBody === "string") {
|
|
174
|
+
return safeBody;
|
|
175
|
+
}
|
|
176
|
+
return JSON.stringify(safeBody);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function addMultipartCurlParts(parts, request) {
|
|
180
|
+
const safeBody = redact(request.body || {});
|
|
181
|
+
for (const [key, value] of Object.entries(safeBody)) {
|
|
182
|
+
if (value === undefined || value === null) continue;
|
|
183
|
+
if (key === "file" || key === "filePath") {
|
|
184
|
+
parts.push("-F", JSON.stringify(`file=@${value}`));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (key === "filename" || key === "contentType") continue;
|
|
188
|
+
if (Array.isArray(value)) {
|
|
189
|
+
for (const item of value) {
|
|
190
|
+
parts.push("-F", JSON.stringify(`${key}=${item}`));
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
parts.push("-F", JSON.stringify(`${key}=${value}`));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function toCurl(request) {
|
|
199
|
+
const parts = ["curl", "-X", request.method, JSON.stringify(request.url)];
|
|
200
|
+
for (const [key, value] of Object.entries(redact(request.headers))) {
|
|
201
|
+
parts.push("-H", JSON.stringify(`${key}: ${value}`));
|
|
202
|
+
}
|
|
203
|
+
if (request.bodyType === "multipart") {
|
|
204
|
+
addMultipartCurlParts(parts, request);
|
|
205
|
+
return parts.join(" ");
|
|
206
|
+
}
|
|
207
|
+
if (request.encodedBody !== undefined) {
|
|
208
|
+
parts.push("--data", JSON.stringify(encodeCurlBody(request)));
|
|
209
|
+
}
|
|
210
|
+
return parts.join(" ");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
buildRequest,
|
|
215
|
+
toCurl,
|
|
216
|
+
mergeObjects,
|
|
217
|
+
resolveBaseUrl
|
|
218
|
+
};
|