@icyouo/evt-cli 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/README.md +76 -7
- package/README.zh-CN.md +66 -7
- package/data/scanner.example.json +2 -5
- package/package.json +7 -3
- package/scripts/build-package.js +173 -0
- package/scripts/check-api-audit.js +260 -0
- package/scripts/check-api-coverage.js +41 -0
- package/scripts/local-ci.js +38 -0
- package/scripts/sync-api-coverage.js +227 -0
- package/skills/evt-api-scanner/SKILL.md +147 -0
- package/skills/evt-api-scanner/scripts/discover.js +160 -0
- package/skills/evt-api-scanner/scripts/scan.js +231 -0
- package/skills/evt-flow-generator/SKILL.md +139 -0
- package/skills/evt-profile-generator/SKILL.md +122 -0
- package/src/config/serviceScanner.js +60 -10
- package/src/index.js +45 -1
- package/src/util/args.js +3 -0
- package/test/apiAudit.test.js +137 -0
- package/test/duration.test.js +11 -0
- package/test/flow.test.js +215 -0
- package/test/liveTester.test.js +54 -0
- package/test/requestBuilder.test.js +214 -0
- package/test/serviceScanner.test.js +196 -0
- package/test/template.test.js +18 -0
- package/test/yaml.test.js +20 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { listProfiles, loadApiRegistry, loadProfile } = require("../src/config/loaders");
|
|
4
|
+
const { compareScanToRegistry, scanServices } = require("../src/config/serviceScanner");
|
|
5
|
+
const { setConfigRoot } = require("../src/util/paths");
|
|
6
|
+
|
|
7
|
+
function toNumber(value, fallback) {
|
|
8
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
if (!Number.isFinite(parsed)) throw new Error(`Expected number, got: ${value}`);
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function camelOption(name) {
|
|
15
|
+
return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseAuditOptions(argv = []) {
|
|
19
|
+
const options = {};
|
|
20
|
+
const booleanOptions = new Set(["strict", "preferFallback", "strictSkill"]);
|
|
21
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
22
|
+
const token = argv[index];
|
|
23
|
+
if (!token.startsWith("--")) continue;
|
|
24
|
+
const raw = token.slice(2);
|
|
25
|
+
const equals = raw.indexOf("=");
|
|
26
|
+
const name = camelOption(equals >= 0 ? raw.slice(0, equals) : raw);
|
|
27
|
+
const inlineValue = equals >= 0 ? raw.slice(equals + 1) : undefined;
|
|
28
|
+
if (booleanOptions.has(name)) {
|
|
29
|
+
options[name] = inlineValue === undefined ? true : inlineValue !== "false";
|
|
30
|
+
} else if (inlineValue !== undefined) {
|
|
31
|
+
options[name] = inlineValue;
|
|
32
|
+
} else {
|
|
33
|
+
index += 1;
|
|
34
|
+
if (index >= argv.length) throw new Error(`--${raw} expects a value`);
|
|
35
|
+
options[name] = argv[index];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return options;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasObjectEntries(value) {
|
|
42
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function schemaGroupHasFields(group) {
|
|
46
|
+
return hasObjectEntries(group);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasMeaningfulSchema(endpoint) {
|
|
50
|
+
const schema = endpoint.schema;
|
|
51
|
+
if (!hasObjectEntries(schema)) return false;
|
|
52
|
+
return schemaGroupHasFields(schema.path) || schemaGroupHasFields(schema.query) || schemaGroupHasFields(schema.body);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasDetailedResponse(endpoint) {
|
|
56
|
+
const response = endpoint.response;
|
|
57
|
+
if (!hasObjectEntries(response)) return false;
|
|
58
|
+
const data = response.data;
|
|
59
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return true;
|
|
60
|
+
if (data.type !== "object") return true;
|
|
61
|
+
return Boolean(data.model || data.fields || data.items || response.envelope);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isGenericResponse(endpoint) {
|
|
65
|
+
return hasObjectEntries(endpoint.response) && !hasDetailedResponse(endpoint);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasUnresolvedPath(endpoint) {
|
|
69
|
+
const value = String(endpoint.path || "");
|
|
70
|
+
return /\$\{|AppConfig|undefined|null/.test(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasUsablePath(endpoint) {
|
|
74
|
+
const value = String(endpoint.path || "");
|
|
75
|
+
return /^https?:\/\//i.test(value) || value.startsWith("/") || Boolean(endpoint.service);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function looksPrivate(endpoint) {
|
|
79
|
+
const haystack = `${endpoint.id || ""} ${endpoint.path || ""}`.toLowerCase();
|
|
80
|
+
return /(user|account|asset|balance|order|position|history|withdraw|deposit|transfer|address|invite|identity|auth|password|bind|google|phone|email)/.test(haystack) &&
|
|
81
|
+
!/(login|register|oauth|public|config|country|currency|currencies|banner|language|version|market|ticker|kline|depth|symbol|spotitems|contractitems)/.test(haystack);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function looksPublic(endpoint) {
|
|
85
|
+
const haystack = `${endpoint.id || ""} ${endpoint.path || ""}`.toLowerCase();
|
|
86
|
+
return /(login|register|oauth|public|config|country|currency|currencies|banner|language|version|market|ticker|kline|depth|symbol|spotitems|contractitems)/.test(haystack);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function looksDangerous(endpoint) {
|
|
90
|
+
const method = String(endpoint.method || "GET").toUpperCase();
|
|
91
|
+
if (method === "GET") return false;
|
|
92
|
+
const haystack = `${endpoint.id || ""} ${endpoint.path || ""}`.toLowerCase();
|
|
93
|
+
return /(create|change|adjust|cancel|close|open|transfer|withdraw|bind|unbind|modify|patch|add|update|delete|disable|identity|set|unlink|margin|leverage|order|password)/.test(haystack) &&
|
|
94
|
+
!/(login|check|send|verify|oauth)/.test(haystack);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function endpointList(registry) {
|
|
98
|
+
return Object.values(registry).sort((left, right) => left.id.localeCompare(right.id));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ratio(count, total) {
|
|
102
|
+
return total === 0 ? 0 : count / total;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function collectProfileServices() {
|
|
106
|
+
const services = new Set();
|
|
107
|
+
const profiles = [];
|
|
108
|
+
for (const profileName of listProfiles()) {
|
|
109
|
+
const profile = loadProfile(profileName);
|
|
110
|
+
profiles.push(profileName);
|
|
111
|
+
for (const service of Object.keys(profile.baseUrls || {})) {
|
|
112
|
+
services.add(service);
|
|
113
|
+
}
|
|
114
|
+
if (profile.baseUrl) services.add("default");
|
|
115
|
+
}
|
|
116
|
+
return { profiles, services };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function summarize(registry, scanned) {
|
|
120
|
+
const endpoints = endpointList(registry);
|
|
121
|
+
const comparison = compareScanToRegistry(scanned, registry);
|
|
122
|
+
const { profiles, services } = collectProfileServices();
|
|
123
|
+
const byMethodPath = new Map();
|
|
124
|
+
for (const endpoint of endpoints) {
|
|
125
|
+
const key = `${String(endpoint.method || "GET").toUpperCase()}:${endpoint.path}`;
|
|
126
|
+
if (!byMethodPath.has(key)) byMethodPath.set(key, []);
|
|
127
|
+
byMethodPath.get(key).push(endpoint.id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const emptySchema = endpoints.filter((endpoint) => !hasMeaningfulSchema(endpoint));
|
|
131
|
+
const missingResponse = endpoints.filter((endpoint) => !hasObjectEntries(endpoint.response));
|
|
132
|
+
const genericResponse = endpoints.filter(isGenericResponse);
|
|
133
|
+
const unresolvedPath = endpoints.filter(hasUnresolvedPath);
|
|
134
|
+
const unusablePath = endpoints.filter((endpoint) => !hasUsablePath(endpoint));
|
|
135
|
+
const unmatchedService = endpoints.filter((endpoint) => endpoint.service && profiles.length > 0 && !services.has(endpoint.service));
|
|
136
|
+
const duplicateMethodPath = Array.from(byMethodPath.entries())
|
|
137
|
+
.filter(([, ids]) => ids.length > 1)
|
|
138
|
+
.map(([key, ids]) => ({ key, ids }));
|
|
139
|
+
const authSuspect = endpoints.filter((endpoint) =>
|
|
140
|
+
(endpoint.auth === false && looksPrivate(endpoint)) ||
|
|
141
|
+
(endpoint.auth === true && looksPublic(endpoint))
|
|
142
|
+
);
|
|
143
|
+
const dangerousSuspect = endpoints.filter((endpoint) => !endpoint.dangerous && looksDangerous(endpoint));
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
scanned: scanned.length,
|
|
147
|
+
endpoints: endpoints.length,
|
|
148
|
+
covered: comparison.covered.length,
|
|
149
|
+
missing: comparison.missing.map((endpoint) => endpoint.id),
|
|
150
|
+
profiles,
|
|
151
|
+
emptySchema: emptySchema.map((endpoint) => endpoint.id),
|
|
152
|
+
missingResponse: missingResponse.map((endpoint) => endpoint.id),
|
|
153
|
+
genericResponse: genericResponse.map((endpoint) => endpoint.id),
|
|
154
|
+
unresolvedPath: unresolvedPath.map((endpoint) => endpoint.id),
|
|
155
|
+
unusablePath: unusablePath.map((endpoint) => endpoint.id),
|
|
156
|
+
unmatchedService: unmatchedService.map((endpoint) => `${endpoint.id}:${endpoint.service}`),
|
|
157
|
+
duplicateMethodPath,
|
|
158
|
+
authSuspect: authSuspect.map((endpoint) => endpoint.id),
|
|
159
|
+
dangerousSuspect: dangerousSuspect.map((endpoint) => endpoint.id),
|
|
160
|
+
ratios: {
|
|
161
|
+
emptySchema: ratio(emptySchema.length, endpoints.length),
|
|
162
|
+
genericResponse: ratio(genericResponse.length, endpoints.length)
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function evaluateStrict(summary, options) {
|
|
168
|
+
const maxMissing = toNumber(options.maxMissing, 0);
|
|
169
|
+
const maxEmptySchemaRatio = toNumber(options.maxEmptySchemaRatio, 0.25);
|
|
170
|
+
const maxGenericResponseRatio = toNumber(options.maxGenericResponseRatio, 0.05);
|
|
171
|
+
const maxUnresolvedPath = toNumber(options.maxUnresolvedPath, 0);
|
|
172
|
+
const maxUnmatchedService = toNumber(options.maxUnmatchedService, 0);
|
|
173
|
+
const maxUnusablePath = toNumber(options.maxUnusablePath, 0);
|
|
174
|
+
const maxDuplicateMethodPath = toNumber(options.maxDuplicateMethodPath, 0);
|
|
175
|
+
const failures = [];
|
|
176
|
+
|
|
177
|
+
if (summary.missing.length > maxMissing) failures.push(`missing scan coverage ${summary.missing.length} > ${maxMissing}`);
|
|
178
|
+
if (summary.ratios.emptySchema > maxEmptySchemaRatio) {
|
|
179
|
+
failures.push(`empty schema ratio ${summary.ratios.emptySchema.toFixed(3)} > ${maxEmptySchemaRatio}`);
|
|
180
|
+
}
|
|
181
|
+
if (summary.ratios.genericResponse > maxGenericResponseRatio) {
|
|
182
|
+
failures.push(`generic response ratio ${summary.ratios.genericResponse.toFixed(3)} > ${maxGenericResponseRatio}`);
|
|
183
|
+
}
|
|
184
|
+
if (summary.unresolvedPath.length > maxUnresolvedPath) failures.push(`unresolved paths ${summary.unresolvedPath.length} > ${maxUnresolvedPath}`);
|
|
185
|
+
if (summary.unmatchedService.length > maxUnmatchedService) failures.push(`unmatched services ${summary.unmatchedService.length} > ${maxUnmatchedService}`);
|
|
186
|
+
if (summary.unusablePath.length > maxUnusablePath) failures.push(`unusable paths ${summary.unusablePath.length} > ${maxUnusablePath}`);
|
|
187
|
+
if (summary.duplicateMethodPath.length > maxDuplicateMethodPath) {
|
|
188
|
+
failures.push(`duplicate method+path ${summary.duplicateMethodPath.length} > ${maxDuplicateMethodPath}`);
|
|
189
|
+
}
|
|
190
|
+
if (summary.missingResponse.length > 0) failures.push(`missing responses ${summary.missingResponse.length} > 0`);
|
|
191
|
+
|
|
192
|
+
return failures;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function compact(summary) {
|
|
196
|
+
return {
|
|
197
|
+
scanned: summary.scanned,
|
|
198
|
+
endpoints: summary.endpoints,
|
|
199
|
+
covered: summary.covered,
|
|
200
|
+
missing: summary.missing,
|
|
201
|
+
profiles: summary.profiles,
|
|
202
|
+
counts: {
|
|
203
|
+
emptySchema: summary.emptySchema.length,
|
|
204
|
+
missingResponse: summary.missingResponse.length,
|
|
205
|
+
genericResponse: summary.genericResponse.length,
|
|
206
|
+
unresolvedPath: summary.unresolvedPath.length,
|
|
207
|
+
unusablePath: summary.unusablePath.length,
|
|
208
|
+
unmatchedService: summary.unmatchedService.length,
|
|
209
|
+
duplicateMethodPath: summary.duplicateMethodPath.length,
|
|
210
|
+
authSuspect: summary.authSuspect.length,
|
|
211
|
+
dangerousSuspect: summary.dangerousSuspect.length
|
|
212
|
+
},
|
|
213
|
+
ratios: summary.ratios,
|
|
214
|
+
samples: {
|
|
215
|
+
emptySchema: summary.emptySchema.slice(0, 25),
|
|
216
|
+
genericResponse: summary.genericResponse.slice(0, 25),
|
|
217
|
+
unresolvedPath: summary.unresolvedPath.slice(0, 25),
|
|
218
|
+
unmatchedService: summary.unmatchedService.slice(0, 25),
|
|
219
|
+
authSuspect: summary.authSuspect.slice(0, 25),
|
|
220
|
+
dangerousSuspect: summary.dangerousSuspect.slice(0, 25)
|
|
221
|
+
},
|
|
222
|
+
duplicateMethodPath: summary.duplicateMethodPath.slice(0, 25)
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function runApiAudit(argv = process.argv.slice(2), options = {}) {
|
|
227
|
+
const auditOptions = parseAuditOptions(argv);
|
|
228
|
+
const strict = Boolean(auditOptions.strict || options.strict);
|
|
229
|
+
setConfigRoot(auditOptions.configRoot);
|
|
230
|
+
const registry = loadApiRegistry();
|
|
231
|
+
const scanned = scanServices(auditOptions);
|
|
232
|
+
const summary = summarize(registry, scanned);
|
|
233
|
+
const failures = strict ? evaluateStrict(summary, auditOptions) : [];
|
|
234
|
+
const payload = {
|
|
235
|
+
ok: failures.length === 0,
|
|
236
|
+
strict: Boolean(strict),
|
|
237
|
+
...compact(summary),
|
|
238
|
+
failures
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (!options.silent) {
|
|
242
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
243
|
+
}
|
|
244
|
+
if (failures.length > 0 && options.exitOnFailure) process.exit(1);
|
|
245
|
+
return { failed: failures.length > 0, payload, summary };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (require.main === module) {
|
|
249
|
+
runApiAudit(process.argv.slice(2), { exitOnFailure: true });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
runApiAudit,
|
|
254
|
+
summarize,
|
|
255
|
+
evaluateStrict,
|
|
256
|
+
parseAuditOptions,
|
|
257
|
+
hasMeaningfulSchema,
|
|
258
|
+
hasDetailedResponse,
|
|
259
|
+
isGenericResponse
|
|
260
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { loadApiRegistry } = require("../src/config/loaders");
|
|
4
|
+
const { compareScanToRegistry, parseCliScanOptions, scanServices } = require("../src/config/serviceScanner");
|
|
5
|
+
const { setConfigRoot } = require("../src/util/paths");
|
|
6
|
+
|
|
7
|
+
function runApiCoverage(argv = process.argv.slice(2), options = {}) {
|
|
8
|
+
const scanOptions = parseCliScanOptions(argv);
|
|
9
|
+
setConfigRoot(scanOptions.configRoot);
|
|
10
|
+
const registry = loadApiRegistry();
|
|
11
|
+
const scanned = scanServices(scanOptions);
|
|
12
|
+
const comparison = compareScanToRegistry(scanned, registry);
|
|
13
|
+
const missingSchema = Object.values(registry)
|
|
14
|
+
.filter((endpoint) => !endpoint.schema)
|
|
15
|
+
.map((endpoint) => endpoint.id);
|
|
16
|
+
const missingResponse = Object.values(registry)
|
|
17
|
+
.filter((endpoint) => !endpoint.response || Object.keys(endpoint.response).length === 0)
|
|
18
|
+
.map((endpoint) => endpoint.id);
|
|
19
|
+
|
|
20
|
+
const failed = comparison.missing.length > 0 || missingSchema.length > 0 || missingResponse.length > 0;
|
|
21
|
+
const payload = {
|
|
22
|
+
scanned: scanned.length,
|
|
23
|
+
covered: comparison.covered.length,
|
|
24
|
+
missing: comparison.missing.map((endpoint) => endpoint.id),
|
|
25
|
+
endpoints: Object.keys(registry).length,
|
|
26
|
+
missingSchema,
|
|
27
|
+
missingResponse
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
31
|
+
if (failed && options.exitOnFailure) process.exit(1);
|
|
32
|
+
return { failed, payload };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (require.main === module) {
|
|
36
|
+
runApiCoverage(process.argv.slice(2), { exitOnFailure: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
runApiCoverage
|
|
41
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
|
|
7
|
+
const cliRoot = path.resolve(__dirname, "..");
|
|
8
|
+
const distDir = path.join(cliRoot, "dist");
|
|
9
|
+
|
|
10
|
+
function run(command, args, options = {}) {
|
|
11
|
+
const label = [command, ...args].join(" ");
|
|
12
|
+
console.log(`\n> ${label}`);
|
|
13
|
+
const result = spawnSync(command, args, {
|
|
14
|
+
cwd: cliRoot,
|
|
15
|
+
stdio: "inherit",
|
|
16
|
+
shell: false,
|
|
17
|
+
...options
|
|
18
|
+
});
|
|
19
|
+
if (result.status !== 0) {
|
|
20
|
+
process.exit(result.status || 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cleanDist() {
|
|
25
|
+
fs.rmSync(distDir, { recursive: true, force: true });
|
|
26
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function main() {
|
|
30
|
+
run("npm", ["test"]);
|
|
31
|
+
run("npm", ["run", "check"]);
|
|
32
|
+
run("node", ["bin/evt.js", "validate"]);
|
|
33
|
+
run("npm", ["run", "coverage:api"]);
|
|
34
|
+
cleanDist();
|
|
35
|
+
run("node", ["scripts/build-package.js"]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main();
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { parseYaml } = require("../src/config/yaml");
|
|
6
|
+
const { loadScanConfig, parseCliScanOptions, scanServices } = require("../src/config/serviceScanner");
|
|
7
|
+
const { resolveCliPath, resolveConfigDir, setConfigRoot } = require("../src/util/paths");
|
|
8
|
+
|
|
9
|
+
const endpointKey = (endpoint) => `${String(endpoint.method || "GET").toUpperCase()}:${endpoint.path}`;
|
|
10
|
+
|
|
11
|
+
function readApiDocument(namespace) {
|
|
12
|
+
const apiDir = resolveApiOutputDir();
|
|
13
|
+
const file = path.join(apiDir, `${namespace}.yaml`);
|
|
14
|
+
if (!fs.existsSync(file)) {
|
|
15
|
+
return { namespace, endpoints: {} };
|
|
16
|
+
}
|
|
17
|
+
return parseYaml(fs.readFileSync(file, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveApiOutputDir(scanOptions = {}) {
|
|
21
|
+
const scanConfig = loadScanConfig(scanOptions);
|
|
22
|
+
if (scanConfig.apiDir) return scanConfig.apiDir;
|
|
23
|
+
const legacyApiDir = resolveCliPath("apis");
|
|
24
|
+
const dataApiDir = resolveCliPath("data", "apis");
|
|
25
|
+
if (fs.existsSync(legacyApiDir)) return legacyApiDir;
|
|
26
|
+
if (fs.existsSync(dataApiDir)) return dataApiDir;
|
|
27
|
+
const resolved = resolveConfigDir("apis");
|
|
28
|
+
return fs.existsSync(resolved) ? resolved : dataApiDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPublicEndpoint(endpoint) {
|
|
32
|
+
const name = endpoint.functionName || endpoint.name || "";
|
|
33
|
+
const pathValue = endpoint.path || "";
|
|
34
|
+
return /^(login|register|oauth|public|health|status|version|languages?|config|list|get|search|check)/i.test(name) ||
|
|
35
|
+
/\/(public|health|status|version|config)\b/i.test(pathValue);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isDangerousEndpoint(endpoint) {
|
|
39
|
+
const method = String(endpoint.method || "GET").toUpperCase();
|
|
40
|
+
if (method === "GET") return false;
|
|
41
|
+
|
|
42
|
+
const name = endpoint.functionName || endpoint.name || "";
|
|
43
|
+
const pathValue = endpoint.path || "";
|
|
44
|
+
if (/^(get|list|history|summary|check|send|login|verify|oauth)/i.test(name) && method === "POST") {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return /create|change|adjust|cancel|close|open|transfer|withdraw|bind|unbind|modify|patch|add|update|delete|disable|deactivate|identity|kyc|set|unlink|margin|leverage|order|password/i
|
|
48
|
+
.test(`${name} ${pathValue}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeEndpoint(scanned) {
|
|
52
|
+
const endpoint = {
|
|
53
|
+
method: scanned.method,
|
|
54
|
+
path: scanned.path,
|
|
55
|
+
auth: scanned.auth !== undefined ? scanned.auth : !isPublicEndpoint(scanned),
|
|
56
|
+
schema: {},
|
|
57
|
+
response: defaultResponse()
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (scanned.service) {
|
|
61
|
+
endpoint.service = scanned.service;
|
|
62
|
+
}
|
|
63
|
+
if (scanned.bodyType && scanned.bodyType !== "json") {
|
|
64
|
+
endpoint.bodyType = scanned.bodyType;
|
|
65
|
+
}
|
|
66
|
+
const dangerous = scanned.dangerous !== undefined ? scanned.dangerous : isDangerousEndpoint(scanned);
|
|
67
|
+
if (dangerous) {
|
|
68
|
+
endpoint.dangerous = true;
|
|
69
|
+
}
|
|
70
|
+
return endpoint;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function augmentEndpoint(namespace, name, endpoint) {
|
|
74
|
+
const inferred = { namespace, name, functionName: name, ...endpoint };
|
|
75
|
+
if (endpoint.dangerous === undefined && isDangerousEndpoint(inferred)) {
|
|
76
|
+
endpoint.dangerous = true;
|
|
77
|
+
}
|
|
78
|
+
endpoint.schema ||= {};
|
|
79
|
+
if (!endpoint.response || Object.keys(endpoint.response).length === 0) {
|
|
80
|
+
endpoint.response = defaultResponse();
|
|
81
|
+
}
|
|
82
|
+
return endpoint;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function defaultResponse() {
|
|
86
|
+
return {
|
|
87
|
+
data: {
|
|
88
|
+
type: "object"
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function uniqueName(endpoints, preferred) {
|
|
94
|
+
if (!Object.prototype.hasOwnProperty.call(endpoints, preferred)) return preferred;
|
|
95
|
+
let suffix = 2;
|
|
96
|
+
while (Object.prototype.hasOwnProperty.call(endpoints, `${preferred}${suffix}`)) {
|
|
97
|
+
suffix += 1;
|
|
98
|
+
}
|
|
99
|
+
return `${preferred}${suffix}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sortObjectByKeys(object) {
|
|
103
|
+
return Object.fromEntries(Object.entries(object).sort(([left], [right]) => left.localeCompare(right)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function orderedEntries(object) {
|
|
107
|
+
const keyOrder = [
|
|
108
|
+
"service",
|
|
109
|
+
"method",
|
|
110
|
+
"path",
|
|
111
|
+
"auth",
|
|
112
|
+
"dangerous",
|
|
113
|
+
"bodyType",
|
|
114
|
+
"headers",
|
|
115
|
+
"query",
|
|
116
|
+
"body",
|
|
117
|
+
"schema",
|
|
118
|
+
"response"
|
|
119
|
+
];
|
|
120
|
+
return Object.entries(object).sort(([left], [right]) => {
|
|
121
|
+
const leftIndex = keyOrder.indexOf(left);
|
|
122
|
+
const rightIndex = keyOrder.indexOf(right);
|
|
123
|
+
if (leftIndex !== -1 || rightIndex !== -1) {
|
|
124
|
+
return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex);
|
|
125
|
+
}
|
|
126
|
+
return left.localeCompare(right);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function yamlScalar(value) {
|
|
131
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
132
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
133
|
+
if (value === null) return "null";
|
|
134
|
+
return JSON.stringify(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeYamlValue(lines, key, value, indent) {
|
|
138
|
+
const prefix = " ".repeat(indent);
|
|
139
|
+
if (Array.isArray(value)) {
|
|
140
|
+
if (value.length === 0) {
|
|
141
|
+
lines.push(`${prefix}${key}: []`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
lines.push(`${prefix}${key}:`);
|
|
145
|
+
for (const item of value) {
|
|
146
|
+
lines.push(`${prefix} - ${yamlScalar(item)}`);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (value && typeof value === "object") {
|
|
151
|
+
const entries = orderedEntries(value);
|
|
152
|
+
if (entries.length === 0) {
|
|
153
|
+
lines.push(`${prefix}${key}: {}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
lines.push(`${prefix}${key}:`);
|
|
157
|
+
for (const [childKey, childValue] of entries) {
|
|
158
|
+
writeYamlValue(lines, childKey, childValue, indent + 2);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
lines.push(`${prefix}${key}: ${yamlScalar(value)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function serializeDocument(document) {
|
|
166
|
+
const lines = [`namespace: ${document.namespace}`, "", "endpoints:"];
|
|
167
|
+
for (const [name, endpoint] of Object.entries(sortObjectByKeys(document.endpoints))) {
|
|
168
|
+
lines.push(` ${name}:`);
|
|
169
|
+
for (const [key, value] of orderedEntries(endpoint)) {
|
|
170
|
+
writeYamlValue(lines, key, value, 4);
|
|
171
|
+
}
|
|
172
|
+
lines.push("");
|
|
173
|
+
}
|
|
174
|
+
return `${lines.join("\n").replace(/\n+$/, "")}\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function runSyncApiCoverage(argv = process.argv.slice(2)) {
|
|
178
|
+
const scanOptions = parseCliScanOptions(argv);
|
|
179
|
+
setConfigRoot(scanOptions.configRoot);
|
|
180
|
+
const apiDir = resolveApiOutputDir(scanOptions);
|
|
181
|
+
fs.mkdirSync(apiDir, { recursive: true });
|
|
182
|
+
const scanned = scanServices(scanOptions);
|
|
183
|
+
const existingNamespaces = fs.existsSync(apiDir)
|
|
184
|
+
? fs.readdirSync(apiDir)
|
|
185
|
+
.filter((file) => file.endsWith(".yaml") || file.endsWith(".yml"))
|
|
186
|
+
.map((file) => path.basename(file, path.extname(file)))
|
|
187
|
+
: [];
|
|
188
|
+
const namespaces = [...new Set([...existingNamespaces, ...scanned.map((endpoint) => endpoint.namespace)])];
|
|
189
|
+
const documents = new Map(namespaces.map((namespace) => [namespace, readApiDocument(namespace)]));
|
|
190
|
+
const coveredPaths = new Set();
|
|
191
|
+
|
|
192
|
+
for (const document of documents.values()) {
|
|
193
|
+
document.endpoints ||= {};
|
|
194
|
+
for (const [name, endpoint] of Object.entries(document.endpoints)) {
|
|
195
|
+
document.endpoints[name] = augmentEndpoint(document.namespace, name, endpoint);
|
|
196
|
+
coveredPaths.add(endpointKey(endpoint));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let added = 0;
|
|
201
|
+
for (const endpoint of scanned) {
|
|
202
|
+
if (coveredPaths.has(endpointKey(endpoint))) continue;
|
|
203
|
+
const document = documents.get(endpoint.namespace);
|
|
204
|
+
const name = uniqueName(document.endpoints, endpoint.functionName);
|
|
205
|
+
document.endpoints[name] = makeEndpoint(endpoint);
|
|
206
|
+
coveredPaths.add(endpointKey(endpoint));
|
|
207
|
+
added += 1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const namespace of namespaces) {
|
|
211
|
+
const document = documents.get(namespace);
|
|
212
|
+
if (!document.endpoints || Object.keys(document.endpoints).length === 0) continue;
|
|
213
|
+
fs.writeFileSync(path.join(apiDir, `${namespace}.yaml`), serializeDocument(document));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const payload = { scanned: scanned.length, added, namespaces: namespaces.length };
|
|
217
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
218
|
+
return payload;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (require.main === module) {
|
|
222
|
+
runSyncApiCoverage();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
runSyncApiCoverage
|
|
227
|
+
};
|