dsh-api-runner 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +435 -2
- package/client/client.js +1824 -0
- package/cordis.patch.yml +9 -0
- package/docs/screenshot-service.png +0 -0
- package/docs/screenshot-settings.png +0 -0
- package/docs/verified-dsh-version.png +0 -0
- package/dsh-api-runner.example.yml +92 -0
- package/package.json +71 -6
- package/scripts/stage.mjs +78 -0
- package/src/api.js +194 -0
- package/src/config.js +265 -0
- package/src/java-runner.js +623 -0
- package/src/java.js +658 -0
- package/src/log.js +209 -0
- package/src/main.js +615 -0
- package/src/ops.js +1257 -0
- package/src/project-store.js +334 -0
- package/src/restart.js +549 -0
- package/src/service.js +329 -0
- package/src/settings.js +109 -0
- package/src/util.js +182 -0
- package/test/client.mjs +162 -0
- package/test/java.mjs +298 -0
- package/test/managed.mjs +137 -0
- package/test/plugin.mjs +467 -0
- package/test/smoke.mjs +528 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-api-runner bundle patch(dsh.bundle.patch:顶层数组 loader patch entries)
|
|
2
|
+
# 安装:dsh plugin --profile desktop add "link:/绝对路径/到/dsh-api-runner"
|
|
3
|
+
#
|
|
4
|
+
# name 写包名时由 DSH 从安装目录解析入口,这是发布形态(提交到仓库、别人装得上的就是这一份)。
|
|
5
|
+
# 开发期改源码后必须让入口 URL 变化(Node 按模块 URL 缓存 ESM),此时跑 `node scripts/stage.mjs`
|
|
6
|
+
# 会把下面这行改成本机 build/vN/main.js 的 file URL;提交前跑 `node scripts/stage.mjs --release` 改回来。
|
|
7
|
+
- insert:
|
|
8
|
+
- id: api-runner
|
|
9
|
+
name: dsh-api-runner
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# dsh-api-runner 配置示例
|
|
2
|
+
# 位置:${DSH_HOME}/api-runner.yml(全局),或 <项目>/.dsh/api-runner.yml(项目级,覆盖全局)
|
|
3
|
+
# 改完保存即生效,不需要重启 DSH。
|
|
4
|
+
#
|
|
5
|
+
# 引用环境变量:${SOME_ENV}(在 url/headers/command 里都可以用)
|
|
6
|
+
# 调用期占位符:{name}(模型调接口时通过 params 传入,会替换 URL 与 body 里的同名占位)
|
|
7
|
+
|
|
8
|
+
defaults:
|
|
9
|
+
timeoutMs: 30000
|
|
10
|
+
|
|
11
|
+
env:
|
|
12
|
+
# 可以在这里定义命名常量,例如:
|
|
13
|
+
# API_HOST: http://127.0.0.1:8080
|
|
14
|
+
# TOKEN: ${DEV_TOKEN}
|
|
15
|
+
|
|
16
|
+
apis:
|
|
17
|
+
# 最小配置
|
|
18
|
+
- id: health
|
|
19
|
+
name: 健康检查
|
|
20
|
+
method: GET
|
|
21
|
+
url: http://127.0.0.1:8080/actuator/health
|
|
22
|
+
|
|
23
|
+
# 路径占位 + query 参数
|
|
24
|
+
- id: get-user
|
|
25
|
+
name: 查询用户
|
|
26
|
+
method: GET
|
|
27
|
+
url: http://127.0.0.1:8080/api/users/{id}
|
|
28
|
+
headers:
|
|
29
|
+
Authorization: "Bearer ${DEV_TOKEN}"
|
|
30
|
+
query:
|
|
31
|
+
verbose: "true"
|
|
32
|
+
|
|
33
|
+
# POST JSON:body 里的 {name} 由模型传参替换
|
|
34
|
+
- id: create-order
|
|
35
|
+
name: 创建订单
|
|
36
|
+
method: POST
|
|
37
|
+
url: http://127.0.0.1:8080/api/orders
|
|
38
|
+
headers:
|
|
39
|
+
Content-Type: application/json
|
|
40
|
+
Authorization: "Bearer ${DEV_TOKEN}"
|
|
41
|
+
body:
|
|
42
|
+
userId: "{userId}"
|
|
43
|
+
amount: "{amount}"
|
|
44
|
+
remark: "{remark}"
|
|
45
|
+
|
|
46
|
+
# POST 表单
|
|
47
|
+
- id: login
|
|
48
|
+
name: 登录
|
|
49
|
+
method: POST
|
|
50
|
+
url: http://127.0.0.1:8080/api/login
|
|
51
|
+
headers:
|
|
52
|
+
Content-Type: application/x-www-form-urlencoded
|
|
53
|
+
body:
|
|
54
|
+
username: "{username}"
|
|
55
|
+
password: "{password}"
|
|
56
|
+
|
|
57
|
+
services:
|
|
58
|
+
# managed:插件托管进程(启动/停止/重启都由插件做,DSH 重启后服务仍在跑)
|
|
59
|
+
- id: app
|
|
60
|
+
name: 本地后端
|
|
61
|
+
mode: managed
|
|
62
|
+
cwd: ./ # 相对配置文件所在目录解析
|
|
63
|
+
# Windows 下 .cmd/.bat 会自动用 shell 启动
|
|
64
|
+
command: ./mvnw.cmd
|
|
65
|
+
args: ["spring-boot:run"]
|
|
66
|
+
env:
|
|
67
|
+
SPRING_PROFILES_ACTIVE: dev
|
|
68
|
+
logPath: ./logs/app.log # 子进程 stdout/stderr 追加到这里
|
|
69
|
+
startupTimeoutMs: 90000 # 等健康检查通过的最长时间
|
|
70
|
+
stopTimeoutMs: 15000
|
|
71
|
+
health:
|
|
72
|
+
url: http://127.0.0.1:8080/actuator/health
|
|
73
|
+
expectStatus: 200
|
|
74
|
+
intervalMs: 1000
|
|
75
|
+
timeoutMs: 2000
|
|
76
|
+
|
|
77
|
+
# observe:进程由 IDEA 启动(用 IDEA 的 restart 按钮),插件只观测
|
|
78
|
+
- id: idea-app
|
|
79
|
+
name: IDEA 里跑的 Spring Boot
|
|
80
|
+
mode: observe
|
|
81
|
+
health:
|
|
82
|
+
url: http://127.0.0.1:8080/actuator/health
|
|
83
|
+
expectStatus: 200
|
|
84
|
+
ports: [8080]
|
|
85
|
+
|
|
86
|
+
logs:
|
|
87
|
+
- id: app-log
|
|
88
|
+
name: 后端日志
|
|
89
|
+
path: ./logs/app.log
|
|
90
|
+
- id: idea-console
|
|
91
|
+
name: IDEA 控制台重定向文件(若你在 Run Configuration 里配了)
|
|
92
|
+
path: ./logs/idea-console.log
|
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-api-runner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-api-runner",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH 插件:按配置调用后端 HTTP 接口、启停/重启本地开发服务、增量读取日志并等待日志特征,让模型能\"改完自己验\"。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Moz (lhhzmz229)",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/lhhzmz229/dsh-api-runner.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/lhhzmz229/dsh-api-runner#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/lhhzmz229/dsh-api-runner/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"api-runner",
|
|
21
|
+
"http-client",
|
|
22
|
+
"log-tail",
|
|
23
|
+
"spring-boot",
|
|
24
|
+
"java",
|
|
25
|
+
"devtools"
|
|
26
|
+
],
|
|
27
|
+
"main": "src/main.js",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./src/main.js",
|
|
30
|
+
"./runner": "./src/main.js",
|
|
31
|
+
"./client": "./client/client.js",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20",
|
|
36
|
+
"dsh": ">=0.1.0-rc.6 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node test/smoke.mjs && node test/plugin.mjs",
|
|
40
|
+
"test:all": "node test/smoke.mjs && node test/plugin.mjs && node test/client.mjs && node test/managed.mjs && node test/java.mjs",
|
|
41
|
+
"stage": "node scripts/stage.mjs",
|
|
42
|
+
"release": "node scripts/stage.mjs --release"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"yaml": "^2.8.0"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"platform": "web",
|
|
53
|
+
"inject": [
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"files": [
|
|
61
|
+
"cordis.patch.yml",
|
|
62
|
+
"src",
|
|
63
|
+
"client",
|
|
64
|
+
"docs",
|
|
65
|
+
"scripts",
|
|
66
|
+
"test",
|
|
67
|
+
"README.md",
|
|
68
|
+
"LICENSE",
|
|
69
|
+
"dsh-api-runner.example.yml"
|
|
70
|
+
]
|
|
71
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stage.mjs —— 把 src/ 打包到一个全新的 build/v<序号>/ 并用它改写 cordis.patch.yml 的入口。
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它:DSH 的插件 loader 用 ESM 动态 import,Node 按 URL 缓存模块。
|
|
6
|
+
* 同一个页面进程里 toggle bundle 只会重跑 apply,不会重新 import 模块,
|
|
7
|
+
* 所以改了 src/ 之后必须让入口 URL 变化,否则继续跑旧代码。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node scripts/stage.mjs # 递增版本号,自动改写 patch
|
|
11
|
+
* node scripts/stage.mjs --clean # 只保留最新一版 build
|
|
12
|
+
* node scripts/stage.mjs --release # 把 patch 的入口改回包名(提交/发布用)
|
|
13
|
+
* 之后到 DSH 里把 dsh-api-runner 这个 bundle 关掉再打开。
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const srcDir = path.join(root, 'src');
|
|
21
|
+
const buildDir = path.join(root, 'build');
|
|
22
|
+
const PACKAGE_NAME = 'dsh-api-runner';
|
|
23
|
+
const patchPath = path.join(root, 'cordis.patch.yml');
|
|
24
|
+
|
|
25
|
+
/** 把 patch 里的入口行改写成给定的 name,返回是否改动过 */
|
|
26
|
+
function rewriteEntry(name) {
|
|
27
|
+
const patch = fs.readFileSync(patchPath, 'utf8');
|
|
28
|
+
const rewritten = patch.replace(/^(\s+name:\s*).*$/m, `$1${name}`);
|
|
29
|
+
if (rewritten === patch && !patch.includes(name)) {
|
|
30
|
+
console.error('cordis.patch.yml 里没找到 name 行,未改写');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
if (rewritten !== patch) fs.writeFileSync(patchPath, rewritten, 'utf8');
|
|
34
|
+
return rewritten !== patch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 发布形态:入口写包名,DSH 从安装目录解析;提交到仓库前跑这个
|
|
38
|
+
if (process.argv.includes('--release')) {
|
|
39
|
+
rewriteEntry(PACKAGE_NAME);
|
|
40
|
+
console.log(`cordis.patch.yml 入口 → ${PACKAGE_NAME}(发布形态,不含本机路径)`);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nextVersion() {
|
|
45
|
+
if (!fs.existsSync(buildDir)) return 1;
|
|
46
|
+
const used = fs.readdirSync(buildDir)
|
|
47
|
+
.map((name) => /^v(\d+)$/.exec(name))
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((m) => Number(m[1]));
|
|
50
|
+
return used.length ? Math.max(...used) + 1 : 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const version = `v${nextVersion()}`;
|
|
54
|
+
const outDir = path.join(buildDir, version);
|
|
55
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
const files = fs.readdirSync(srcDir).filter((name) => name.endsWith('.js'));
|
|
58
|
+
for (const name of files) fs.copyFileSync(path.join(srcDir, name), path.join(outDir, name));
|
|
59
|
+
|
|
60
|
+
// 入口统一叫 main.js(与 src/main.js 对齐),loader 只需一个入口 URL。
|
|
61
|
+
const entry = path.join(outDir, 'main.js');
|
|
62
|
+
if (!fs.existsSync(entry)) {
|
|
63
|
+
console.error(`src/main.js 不存在,无法确定插件入口`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const entryUrl = pathToFileURL(entry).href;
|
|
68
|
+
rewriteEntry(entryUrl);
|
|
69
|
+
|
|
70
|
+
if (process.argv.includes('--clean')) {
|
|
71
|
+
for (const name of fs.readdirSync(buildDir)) {
|
|
72
|
+
if (name !== version) fs.rmSync(path.join(buildDir, name), { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(`已发布 ${files.length} 个文件 → ${outDir}`);
|
|
77
|
+
console.log(`cordis.patch.yml 入口 → ${entryUrl}`);
|
|
78
|
+
console.log('下一步:在 DSH 里把 dsh-api-runner bundle 关掉再打开(set_bundle false → true)');
|
package/src/api.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 接口调用:按配置里的接口定义发 HTTP 请求。
|
|
3
|
+
*
|
|
4
|
+
* 约定:
|
|
5
|
+
* - url 支持 {param} 路径占位,params 里的键会同时用于路径替换与 query 拼接;
|
|
6
|
+
* - body 支持 {param} 模板展开;若 body 是对象且定义了 json 类型,序列化为 JSON;
|
|
7
|
+
* - 超时用 AbortSignal.timeout,失败统一返回结构化结果而不是抛异常(工具层好渲染)。
|
|
8
|
+
*/
|
|
9
|
+
import { expandBraces, expandDeep, isPlainObject, truncateText, tryJson } from './util.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
12
|
+
|
|
13
|
+
/** 把 params 里未被 url 占位消耗的键拼成 query string */
|
|
14
|
+
function buildQuery(query, params, url) {
|
|
15
|
+
const search = new URLSearchParams();
|
|
16
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
17
|
+
if (value === undefined || value === null) continue;
|
|
18
|
+
search.append(key, String(expandBraces(value, params)));
|
|
19
|
+
}
|
|
20
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
21
|
+
if (value === undefined || value === null) continue;
|
|
22
|
+
// url 占位已用的键不再重复进 query
|
|
23
|
+
if (url.includes(`{${key}}`)) continue;
|
|
24
|
+
if (key in (query || {})) continue;
|
|
25
|
+
search.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
26
|
+
}
|
|
27
|
+
const text = search.toString();
|
|
28
|
+
return text === '' ? '' : `?${text}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 拼接完整 URL:baseUrl + url(+ query),并对展开后的地址做合法性校验 */
|
|
32
|
+
function buildUrl(api, params) {
|
|
33
|
+
let target = expandBraces(api.url, params);
|
|
34
|
+
if (api.baseUrl) {
|
|
35
|
+
const base = api.baseUrl.endsWith('/') ? api.baseUrl.slice(0, -1) : api.baseUrl;
|
|
36
|
+
if (!/^https?:\/\//i.test(target)) target = `${base}/${target.replace(/^\//, '')}`;
|
|
37
|
+
}
|
|
38
|
+
const queryText = buildQuery(api.query, params, api.url).replace(/^\?/, '');
|
|
39
|
+
let normalized = target;
|
|
40
|
+
if (queryText !== '') normalized = `${target}${target.includes('?') ? '&' : '?'}${queryText}`;
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(normalized);
|
|
43
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
44
|
+
throw new Error(`不支持的协议 ${parsed.protocol}`);
|
|
45
|
+
}
|
|
46
|
+
return parsed.toString();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(`接口 ${api.id} 的 URL 非法:${normalized}(${error.message})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 依据 method 决定是否带 body,并按 content-type 处理。
|
|
54
|
+
*
|
|
55
|
+
* 注意:headers 是**真实要发出去的**请求头对象,这里必须原地补 content-type。
|
|
56
|
+
* 早期实现内部又拷贝了一份,导致 JSON body 实际以 fetch 默认的
|
|
57
|
+
* `text/plain;charset=UTF-8` 发出,Spring 端直接 500:
|
|
58
|
+
* `Content type 'text/plain;charset=UTF-8' not supported`。
|
|
59
|
+
*/
|
|
60
|
+
function buildBody(api, params, headers) {
|
|
61
|
+
const method = api.method;
|
|
62
|
+
if (method === 'GET' || method === 'HEAD') return undefined;
|
|
63
|
+
if (api.body === undefined || api.body === null) return undefined;
|
|
64
|
+
|
|
65
|
+
const expanded = expandDeep(api.body, {}, params);
|
|
66
|
+
const contentType = findHeader(headers, 'content-type');
|
|
67
|
+
if (typeof expanded === 'string') {
|
|
68
|
+
// body 是字符串时,如果内容看起来是 JSON 就按 JSON 发;否则明确用 text/plain。
|
|
69
|
+
// 两者都必须**显式**设置:fetch 在没设 content-type 时默认 text/plain,
|
|
70
|
+
// 对 Spring 的 @RequestBody JSON 接口会直接 500。
|
|
71
|
+
if (!contentType) {
|
|
72
|
+
setHeader(headers, 'content-type', tryJson(expanded) !== undefined
|
|
73
|
+
? 'application/json; charset=utf-8'
|
|
74
|
+
: 'text/plain; charset=utf-8');
|
|
75
|
+
}
|
|
76
|
+
return expanded;
|
|
77
|
+
}
|
|
78
|
+
if (contentType && /x-www-form-urlencoded/i.test(contentType)) {
|
|
79
|
+
return new URLSearchParams(flattenPairs(expanded)).toString();
|
|
80
|
+
}
|
|
81
|
+
if (!contentType) setHeader(headers, 'content-type', 'application/json; charset=utf-8');
|
|
82
|
+
return JSON.stringify(expanded);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function flattenPairs(value) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const [key, item] of Object.entries(value || {})) {
|
|
88
|
+
if (item === undefined || item === null) continue;
|
|
89
|
+
out.push([key, typeof item === 'object' ? JSON.stringify(item) : String(item)]);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeHeaders(headers) {
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
97
|
+
if (value === undefined || value === null) continue;
|
|
98
|
+
out[key] = String(value);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findHeader(headers, name) {
|
|
104
|
+
const key = Object.keys(headers).find((item) => item.toLowerCase() === name.toLowerCase());
|
|
105
|
+
return key ? headers[key] : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setHeader(headers, name, value) {
|
|
109
|
+
const existing = Object.keys(headers).find((item) => item.toLowerCase() === name.toLowerCase());
|
|
110
|
+
if (existing) headers[existing] = value;
|
|
111
|
+
else headers[name] = value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 执行一次接口调用。
|
|
116
|
+
* @returns {Promise<{ok:boolean,apiId:string,method:string,url:string,status:number,statusText:string,
|
|
117
|
+
* durationMs:number,headers:Record<string,string>,body:string,json?:unknown,error?:string}>}
|
|
118
|
+
*/
|
|
119
|
+
export async function callApi(api, params = {}, options = {}) {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
const timeoutMs = Number(options.timeoutMs || api.timeoutMs || DEFAULT_TIMEOUT);
|
|
122
|
+
const result = {
|
|
123
|
+
ok: false,
|
|
124
|
+
apiId: api.id,
|
|
125
|
+
name: api.name,
|
|
126
|
+
method: api.method,
|
|
127
|
+
url: '',
|
|
128
|
+
status: 0,
|
|
129
|
+
statusText: '',
|
|
130
|
+
durationMs: 0,
|
|
131
|
+
headers: {},
|
|
132
|
+
body: '',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let url;
|
|
136
|
+
let headers;
|
|
137
|
+
let body;
|
|
138
|
+
try {
|
|
139
|
+
url = buildUrl(api, params);
|
|
140
|
+
headers = normalizeHeaders(expandDeep(api.headers || {}, {}, params));
|
|
141
|
+
body = buildBody(api, params, headers);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
result.error = error.message;
|
|
144
|
+
result.durationMs = Date.now() - started;
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
result.url = url;
|
|
148
|
+
|
|
149
|
+
if (api.baseUrl) setHeader(headers, 'accept', findHeader(headers, 'accept') || 'application/json, text/plain, */*');
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(url, {
|
|
153
|
+
method: api.method,
|
|
154
|
+
headers,
|
|
155
|
+
body,
|
|
156
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
157
|
+
redirect: 'follow',
|
|
158
|
+
});
|
|
159
|
+
result.status = response.status;
|
|
160
|
+
result.statusText = response.statusText;
|
|
161
|
+
result.ok = response.ok;
|
|
162
|
+
for (const [key, value] of response.headers.entries()) result.headers[key] = value;
|
|
163
|
+
const text = await response.text();
|
|
164
|
+
const maxBytes = Number(options.maxBodyBytes || 32 * 1024);
|
|
165
|
+
result.body = truncateText(text, maxBytes);
|
|
166
|
+
const parsed = tryJson(text);
|
|
167
|
+
if (parsed !== undefined) result.json = parsed;
|
|
168
|
+
if (text.length > (result.body || '').length) result.truncated = true;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
result.error = error.name === 'TimeoutError'
|
|
171
|
+
? `请求超时(${timeoutMs}ms)`
|
|
172
|
+
: String(error?.message || error);
|
|
173
|
+
}
|
|
174
|
+
result.durationMs = Date.now() - started;
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 从响应 JSON 里按简单路径取值(a.b[0].c),用于校验 */
|
|
179
|
+
export function pickPath(value, dotted) {
|
|
180
|
+
if (!dotted || !isPlainObject(Object(value)) && !Array.isArray(value)) return undefined;
|
|
181
|
+
let cursor = value;
|
|
182
|
+
for (const raw of String(dotted).split('.')) {
|
|
183
|
+
if (cursor === undefined || cursor === null) return undefined;
|
|
184
|
+
const match = /^([^[\]]*)((\[\d+\])*)$/.exec(raw);
|
|
185
|
+
if (!match) return undefined;
|
|
186
|
+
const [, head, indexes] = match;
|
|
187
|
+
if (head) cursor = cursor[head];
|
|
188
|
+
for (const idx of indexes.match(/\d+/g) || []) {
|
|
189
|
+
if (!Array.isArray(cursor)) return undefined;
|
|
190
|
+
cursor = cursor[Number(idx)];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return cursor;
|
|
194
|
+
}
|