@batype/chainspec 2.1.0 → 2.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/README.md +78 -6
- package/bin/chainspec.mjs +43 -7
- package/bin/translation-provider.d.ts +2 -0
- package/bin/translation-provider.mjs +64 -0
- package/bin/translation-server.mjs +82 -0
- package/examples/translation.config.json +16 -0
- package/examples/translation.env.example +11 -0
- package/lib/core/config.d.ts +4 -1
- package/lib/core/config.js +57 -6
- package/lib/core/index.d.ts +8 -3
- package/lib/core/index.js +23 -3
- package/lib/core/model.d.ts +11 -0
- package/lib/core/parse.js +3 -1
- package/lib/core/translate.d.ts +22 -0
- package/lib/core/translate.js +150 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ API.main.users.getUser.request({path:{id:'42'}});
|
|
|
7
7
|
API.main.users.getUser.useRequest({path:{id:'42'}});
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
React + Vite本地生成器,普通Node/browser SDK与React/SWR
|
|
10
|
+
React + Vite本地生成器,普通Node/browser SDK与React/SWR入口分开。**不执行生成代码,不发送业务请求,不上传整份接口文档。可选自动翻译只发送缺失的分组名称。**
|
|
11
11
|
|
|
12
12
|
[在线使用](https://batype.github.io/chainspec/) · [需求与设计](docs/design.md) · [支持边界](docs/limits.md) · [验证记录](docs/validation.md)
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ React + Vite本地生成器,普通Node/browser SDK与React/SWR入口分开。*
|
|
|
15
15
|
|
|
16
16
|
## 一次完整流程
|
|
17
17
|
|
|
18
|
-
导入文件、粘贴JSON或从URL获取 →
|
|
18
|
+
导入文件、粘贴JSON或从URL获取 → 填写配置表单 → 生成 → 搜索接口并预览调用、类型与诊断 → 下载完整SDK ZIP。输入与配置改动后,旧结果暂停复制/下载。输入面板可以折叠。支持亮色、暗色、系统主题和手机布局。
|
|
19
19
|
|
|
20
20
|
支持Swagger2.0、OpenAPI3.0/3.1 JSON的常见子集,本地引用、递归模型、枚举、组合类型、参数与JSON/平面表单。没有模型的响应使用unknown并提示;不是完整的OpenAPI或JSON Schema实现。远程引用、YAML和Swagger UI HTML需先转换。
|
|
21
21
|
|
|
@@ -121,7 +121,7 @@ node bin/chainspec.mjs --in openapi.json --out ./new-sdk --namespace main --stri
|
|
|
121
121
|
|
|
122
122
|
### 一份 JSON 配置,网页与代码库共用
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
网页的配置区使用表单填写,点击旁边“查看配置”可预览、复制JSON;支持配置导入/导出,导入后回填表单。完整SDK ZIP也包含`chainspec.config.json`。配置格式版本`version: 1`与生成器npm版本独立。
|
|
125
125
|
|
|
126
126
|
```json
|
|
127
127
|
{
|
|
@@ -138,7 +138,7 @@ node bin/chainspec.mjs --in openapi.json --out ./new-sdk --namespace main --stri
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
```sh
|
|
141
|
-
npm install -D @batype/chainspec@^2.
|
|
141
|
+
npm install -D @batype/chainspec@^2.2.0
|
|
142
142
|
npx chainspec --config chainspec.config.json
|
|
143
143
|
# 接口更新后重新生成:
|
|
144
144
|
npx chainspec --config chainspec.config.json --update
|
|
@@ -150,7 +150,7 @@ npx chainspec --in https://example.com/api-json --out ./new-sdk
|
|
|
150
150
|
|
|
151
151
|
`input`也可以是`./openapi.json`,在代码库保留对应文件即可。配置文件中的本地input/output路径相对于配置文件;显式CLI参数覆盖JSON,并相对于当前工作目录。还可配置`stripPrefix`、`baseUrl`。未知键及错误类型直接报错,配置文件不执行JavaScript。网页只读取当前导入的JSON,不根据本地路径自动读磁盘;配置中的URL需要点击“获取JSON”。输出目录只用于CLI。
|
|
152
152
|
|
|
153
|
-
首个Tag
|
|
153
|
+
首个Tag保留为分组依据,中文标签仍保留在说明和搜索中。2.2起网页/CLI默认自动翻译缺失的中文分组;已有映射直接复用,`translateNames:false`或`--no-translate`关闭外部翻译。调用链只生成ASCII标识符:显式`controllerNames`优先,未映射的非ASCII分组从路径推导并提示;同一Tag保持同一分组,重名使用稳定后缀。建议为中文Tag显式映射并提交配置,让名字在路径变化后仍保持稳定。2.0已有中文调用名的SDK需重新生成并修改调用;原有英文别名与v2入参约定保持不变。
|
|
154
154
|
|
|
155
155
|
`--update`通过CLI生成的文件清单和SHA256检查所有生成文件;手改文件、额外文件、符号链接、未由CLI管理的旧目录会拒绝更新。自定义请求与业务代码放在SDK目录外。没有`--update`仍不会覆盖已有目录。
|
|
156
156
|
|
|
@@ -165,6 +165,78 @@ API.main.users.listOrders.request({
|
|
|
165
165
|
// useRequest使用同样的位置;具体字段由文档推导。
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
+
### 2.2 自动翻译、API Key 与名称缓存
|
|
169
|
+
|
|
170
|
+
网页支持 MyMemory(免密钥)、有道智云、百度翻译和 Google Cloud Translation。只翻译当前分组规则下缺少映射的中文名称,同一分组每次只请求一次;`controllerNames`中的手工或粘贴值优先。英文结果转为camelCase,例如`SMS Management`→`smsManagement`。已有名称不会因为切换翻译服务而改变。
|
|
171
|
+
|
|
172
|
+
配置示例:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"version": 1,
|
|
177
|
+
"input": "./openapi.json",
|
|
178
|
+
"output": "./src/api",
|
|
179
|
+
"groupBy": "tag",
|
|
180
|
+
"translateNames": true,
|
|
181
|
+
"translation": {
|
|
182
|
+
"provider": "baidu",
|
|
183
|
+
"appIdEnv": "BAIDU_TRANSLATE_APP_ID",
|
|
184
|
+
"apiKeyEnv": "BAIDU_TRANSLATE_SECRET_KEY",
|
|
185
|
+
"proxyUrl": "http://127.0.0.1:4190/translate"
|
|
186
|
+
},
|
|
187
|
+
"controllerNames": {}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
真实密钥放在配置旁边的`.env`中,不写到JSON、SDK或翻译缓存中:
|
|
192
|
+
|
|
193
|
+
```dotenv
|
|
194
|
+
BAIDU_TRANSLATE_APP_ID=你的应用ID
|
|
195
|
+
BAIDU_TRANSLATE_SECRET_KEY=你的密钥
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| provider | apiKeyEnv 默认值 | appIdEnv 默认值 |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| google | GOOGLE_TRANSLATE_API_KEY | 不需要 |
|
|
201
|
+
| baidu | BAIDU_TRANSLATE_SECRET_KEY | BAIDU_TRANSLATE_APP_ID |
|
|
202
|
+
| youdao | YOUDAO_TRANSLATE_SECRET_KEY | YOUDAO_TRANSLATE_APP_KEY |
|
|
203
|
+
| mymemory | 不需要 | 不需要 |
|
|
204
|
+
|
|
205
|
+
CLI自动读取配置旁边的`.env`,系统已有环境变量优先。也可用`--env-file PATH`指定文件。可以复制[配置示例](examples/translation.config.json)和[环境变量模板](examples/translation.env.example)。
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
npx chainspec --config chainspec.config.json --update
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
成功翻译会写回配置文件的`controllerNames`,SDK中也有`controller-names.json`。这些JSON是可提交到代码库的名称缓存,下一次生成优先复用,无需再次调用翻译API;`--update`也会读取原SDK的缓存。CLI不覆盖生成期间被其他程序修改的配置。失败项只回退路径命名并诊断,不写入成功缓存,下次可以重试;不会改用其他服务发送名称。CLI未成功写回时会提示,已生成SDK中的缓存仍保留。
|
|
212
|
+
|
|
213
|
+
网页默认在当前浏览器缓存名称,也把映射写入下载配置与ZIP中的`controller-names.json`。支持粘贴JSON、完整配置中的controllerNames,或每行`原分组=英文名`;支持复制映射和清除浏览器缓存。浏览器无法直接改写代码库文件,需将导出的配置保存回工程。
|
|
214
|
+
|
|
215
|
+
#### 网页使用带密钥的服务
|
|
216
|
+
|
|
217
|
+
GitHub Pages是静态网页。网页通过你自己的翻译代理调用有道、百度或Google,密钥保留在本机。包内附带本地代理:
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
# 配置和.env准备好后运行:
|
|
221
|
+
npx chainspec translate-server --config chainspec.config.json --origin https://batype.github.io
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
将网页服务选择与JSON配置保持一致,代理地址填写`http://127.0.0.1:4190/translate`。开发预览时把`--origin`改成当前页面的origin(如`http://127.0.0.1:4185`)。代理只监听127.0.0.1,只允许指定origin,只接收分组名称和服务名,不接收浏览器传入的密钥。浏览器可能要求本地网络访问许可;也可在自己的后端部署HTTPS代理,按同样协议实现:`POST {text,provider}`→`{translatedText}`。CLI直接访问官方服务,`proxyUrl`仅用于网页。
|
|
225
|
+
|
|
226
|
+
Node适配器使用[百度MD5签名](https://fanyi-api.baidu.com/doc_bd/21)、[有道v3 SHA256签名](https://ai.youdao.com/DOCSIRMA/html/trans/api/wbfy/index.html)、[Google Basic v2 API Key](https://docs.cloud.google.com/translate/docs/reference/rest/v2/translate)。MyMemory匿名额度目前为[每日5000字符](https://mymemory.translated.net/doc/usagelimits.php),单次500字节;其他服务按用户账户权限和配额计费。本工具自动翻译每次最多100个分组、5000字符,每个名称最多500字节;超出部分会提示并回退路径命名,已有缓存继续生效。
|
|
227
|
+
|
|
228
|
+
同步`generateSDK`始终离线。异步API可配置自己的翻译方法;Node官方适配器也可嵌入你的后端:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import {generateSDKAsync} from '@batype/chainspec';
|
|
232
|
+
import {createNodeTranslator} from '@batype/chainspec/node';
|
|
233
|
+
const translation = {provider:'google',apiKeyEnv:'GOOGLE_TRANSLATE_API_KEY'} as const;
|
|
234
|
+
const project = await generateSDKAsync(jsonText, {groupBy:'tag',translation}, {
|
|
235
|
+
translate:createNodeTranslator(translation)
|
|
236
|
+
});
|
|
237
|
+
// 保存project.controllerNames供下次复用。
|
|
238
|
+
```
|
|
239
|
+
|
|
168
240
|
Node生成器API:
|
|
169
241
|
|
|
170
242
|
```ts
|
|
@@ -186,7 +258,7 @@ npx --yes --package=@batype/chainspec chainspec --in openapi.json --out ./new-sd
|
|
|
186
258
|
|
|
187
259
|
## 数据与安全边界
|
|
188
260
|
|
|
189
|
-
只有点击获取链接或执行CLI时才GET指定的JSON;浏览器不携带Cookie/认证、不经过代理,目标站需允许CORS,HTTPS页面不能获取HTTP资源。生产CSP仅为URL导入允许HTTP(S)连接;Worker仍只解析文档,不获取外部引用。URL导入有20秒超时及8MiB
|
|
261
|
+
只有点击获取链接或执行CLI时才GET指定的JSON;浏览器不携带Cookie/认证、不经过代理,目标站需允许CORS,HTTPS页面不能获取HTTP资源。生产CSP仅为URL导入允许HTTP(S)连接;Worker仍只解析文档,不获取外部引用。URL导入有20秒超时及8MiB流式限制,可取消。仅主题和英文分组名称缓存持久化,无云历史或遥测。输入最大8MiB、5000操作/模型。清空后移除当前输入和结果。不要未经检查就公开包含业务模型、地址和枚举的生成SDK。仓库、截图和测试使用合成API;用户提供的文档不进入公开源码。
|
|
190
262
|
|
|
191
263
|
类型不验证服务端数据;不猜鉴权凭据;不保证复杂序列化和所有Schema约束。int64依然是JS number,需要精度时由服务器返回string。参阅[限制](docs/limits.md)。
|
|
192
264
|
|
package/bin/chainspec.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile, mkdir, writeFile, lstat, rm, readdir, rename } from "node:fs/promises";
|
|
3
3
|
import { resolve, dirname, basename, join } from "node:path";
|
|
4
|
+
import { loadEnvFile } from "node:process";
|
|
5
|
+
import { createNodeTranslator } from "./translation-provider.mjs";
|
|
4
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
7
|
+
import { generateSDKAsync, parseConfig, parseControllerNames, fetchDocument, MAX_BYTES, VERSION } from "../lib/core/index.js";
|
|
6
8
|
const args = process.argv.slice(2), opts = {};
|
|
7
9
|
const marker = ".chainspec-generated.json";
|
|
8
10
|
const digest = text => createHash("sha256").update(text).digest("hex");
|
|
9
|
-
let inputPath, output, configPath, update = false;
|
|
11
|
+
let inputPath, output, configPath, envPath, update = false;
|
|
10
12
|
async function localJSON(path) {
|
|
11
13
|
const stat = await lstat(path);
|
|
12
14
|
if (!stat.isFile() || stat.size > MAX_BYTES)
|
|
@@ -18,8 +20,12 @@ async function exists(path) {
|
|
|
18
20
|
catch (e) { if (e.code === "ENOENT") return null; throw e; }
|
|
19
21
|
}
|
|
20
22
|
try {
|
|
23
|
+
if (args[0] === "translate-server") {
|
|
24
|
+
const { runTranslationServer } = await import("./translation-server.mjs");
|
|
25
|
+
await runTranslationServer(args.slice(1));
|
|
26
|
+
} else {
|
|
21
27
|
if (args.includes("--help") || !args.length) {
|
|
22
|
-
console.log("chainspec --config chainspec.config.json [--update]\nchainspec --in openapi.json|https://example.com/api-json --out ./new-sdk [--namespace main] [--strip-prefix /v1/api] [--group-by path|tag] [--base-url URL] [--no-hooks]\nPaths in JSON resolve relative to the config file; explicit CLI flags override JSON.\nCreates a new directory. --update replaces only an unchanged CLI-generated directory.");
|
|
28
|
+
console.log("chainspec --config chainspec.config.json [--update] [--no-translate] [--env-file .env]\nchainspec translate-server --config chainspec.config.json [--env-file .env] [--origin https://batype.github.io]\nchainspec --in openapi.json|https://example.com/api-json --out ./new-sdk [--namespace main] [--strip-prefix /v1/api] [--group-by path|tag] [--base-url URL] [--no-hooks]\nPaths in JSON resolve relative to the config file; explicit CLI flags override JSON.\nCreates a new directory. --update replaces only an unchanged CLI-generated directory.");
|
|
23
29
|
process.exit(0);
|
|
24
30
|
}
|
|
25
31
|
if (args.includes("--version")) { console.log(VERSION); process.exit(0); }
|
|
@@ -27,16 +33,20 @@ try {
|
|
|
27
33
|
const flag = args[i];
|
|
28
34
|
if (flag === "--no-hooks") { opts.hooks = false; continue; }
|
|
29
35
|
if (flag === "--update") { update = true; continue; }
|
|
30
|
-
if (
|
|
36
|
+
if (flag === "--translate") { opts.translateNames = true; continue; }
|
|
37
|
+
if (flag === "--no-translate") { opts.translateNames = false; continue; }
|
|
38
|
+
if (!["--env-file", "--config", "--in", "--out", "--namespace", "--strip-prefix", "--group-by", "--base-url"].includes(flag))
|
|
31
39
|
throw Error("Unknown option; use --help");
|
|
32
40
|
const val = args[++i];
|
|
33
41
|
if (!val || val.startsWith("--")) throw Error("Missing option value");
|
|
34
|
-
if (flag === "--
|
|
42
|
+
if (flag === "--env-file") envPath = resolve(val);
|
|
43
|
+
else if (flag === "--config") configPath = resolve(val);
|
|
35
44
|
else if (flag === "--in") inputPath = val;
|
|
36
45
|
else if (flag === "--out") output = val;
|
|
37
46
|
else opts[{"--namespace":"namespace", "--strip-prefix":"stripPrefix", "--group-by":"groupBy", "--base-url":"baseUrl"}[flag]] = val;
|
|
38
47
|
}
|
|
39
|
-
const
|
|
48
|
+
const configText = configPath ? await localJSON(configPath) : undefined;
|
|
49
|
+
const config = configText ? parseConfig(configText) : undefined;
|
|
40
50
|
const base = configPath ? dirname(configPath) : process.cwd();
|
|
41
51
|
const source = inputPath ?? config?.input;
|
|
42
52
|
const destination = output ? resolve(output) : config ? resolve(base, config.output) : undefined;
|
|
@@ -46,7 +56,6 @@ try {
|
|
|
46
56
|
const text = /^[a-z][a-z\d+.-]*:/i.test(source)
|
|
47
57
|
? await fetchDocument(source)
|
|
48
58
|
: await localJSON(resolve(inputPath ? process.cwd() : base, source));
|
|
49
|
-
const result = generateSDK(text, settings);
|
|
50
59
|
const dir = destination;
|
|
51
60
|
const old = await exists(dir);
|
|
52
61
|
if (old) {
|
|
@@ -67,6 +76,20 @@ try {
|
|
|
67
76
|
throw Error("Generated files changed; choose a new directory to preserve edits");
|
|
68
77
|
}
|
|
69
78
|
}
|
|
79
|
+
let cachedNames = {};
|
|
80
|
+
if (old && await exists(join(dir, "controller-names.json")))
|
|
81
|
+
cachedNames = parseControllerNames(await localJSON(join(dir, "controller-names.json")));
|
|
82
|
+
if (envPath) loadEnvFile(envPath);
|
|
83
|
+
else if (configPath) {
|
|
84
|
+
try { loadEnvFile(resolve(base, ".env")); }
|
|
85
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
86
|
+
}
|
|
87
|
+
const translate = createNodeTranslator(settings.translation ?? { provider: "mymemory" });
|
|
88
|
+
const result = await generateSDKAsync(text, {
|
|
89
|
+
...settings, controllerNames: { ...cachedNames, ...settings.controllerNames },
|
|
90
|
+
}, { translate });
|
|
91
|
+
for (const diagnostic of result.diagnostics.filter(d => d.code.startsWith("translation-")))
|
|
92
|
+
console.warn(diagnostic.message);
|
|
70
93
|
await mkdir(dirname(dir), { recursive: true });
|
|
71
94
|
const stage = join(dirname(dir), `.${basename(dir)}.chainspec-${randomUUID()}`);
|
|
72
95
|
await mkdir(stage, { mode: 0o700 });
|
|
@@ -88,7 +111,20 @@ try {
|
|
|
88
111
|
catch (e) { await rm(dir, { recursive: true, force: true }); throw e; }
|
|
89
112
|
}
|
|
90
113
|
} finally { await rm(stage, { recursive: true, force: true }); }
|
|
114
|
+
if (configPath && config && JSON.stringify(config.controllerNames ?? {}) !== JSON.stringify(result.controllerNames)) {
|
|
115
|
+
const temp = `${configPath}.chainspec-${randomUUID()}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
if (await localJSON(configPath) !== configText) throw Error("Configuration changed during generation");
|
|
118
|
+
const mode = (await lstat(configPath)).mode & 0o777;
|
|
119
|
+
await writeFile(temp, JSON.stringify({ ...config, controllerNames: result.controllerNames }, null, 2) + "\n", { flag: "wx", mode });
|
|
120
|
+
await rename(temp, configPath);
|
|
121
|
+
console.log("Saved English group names to config; future runs reuse them without translation.");
|
|
122
|
+
} catch {
|
|
123
|
+
console.warn("SDK generated, but config cache was not saved (changed or not writable). Names remain in controller-names.json.");
|
|
124
|
+
} finally { await rm(temp, { force: true }); }
|
|
125
|
+
}
|
|
91
126
|
console.log(`Generated ${result.operations.length} operations, ${result.schemaCount} schemas, ${result.diagnostics.length} diagnostics.\nOutput: ${dir}`);
|
|
127
|
+
}
|
|
92
128
|
} catch (error) {
|
|
93
129
|
console.error(error?.code === "EEXIST" ? "Output already exists; choose a new directory or use --update." : error instanceof Error ? error.message : "Generation failed");
|
|
94
130
|
process.exitCode = 1;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { parseTranslationConfig, translateWithMyMemory, TranslationError, readTranslationResponse } from "../lib/core/index.js";
|
|
4
|
+
|
|
5
|
+
/** Official provider adapters. Secrets are read lazily and never returned or logged. */
|
|
6
|
+
export function createNodeTranslator(config = { provider: "mymemory" }, env = process.env, transport = fetch) {
|
|
7
|
+
const options = parseTranslationConfig(config);
|
|
8
|
+
let nextStart = 0;
|
|
9
|
+
const required = (key) => {
|
|
10
|
+
const value = env[key];
|
|
11
|
+
if (typeof value !== "string" || !value.trim()) throw new TranslationError(`缺少环境变量 ${key},请在本地 .env 或系统环境变量中配置。`);
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
return async (text, signal) => {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
if (typeof text !== "string" || !text || Buffer.byteLength(text) > 500)
|
|
17
|
+
throw new TranslationError("自动翻译仅接受 500 字节以内的分组名称。");
|
|
18
|
+
if (options.provider === "mymemory") return translateWithMyMemory(text, signal);
|
|
19
|
+
const key = required(options.apiKeyEnv);
|
|
20
|
+
const appId = options.provider === "google" ? undefined : required(options.appIdEnv);
|
|
21
|
+
if (options.provider === "baidu") {
|
|
22
|
+
const now = Date.now(), start = Math.max(now, nextStart);
|
|
23
|
+
nextStart = start + 1100;
|
|
24
|
+
if (start > now) await delay(start - now, undefined, { signal });
|
|
25
|
+
}
|
|
26
|
+
const salt = randomUUID().replaceAll("-", "");
|
|
27
|
+
let url, body;
|
|
28
|
+
if (options.provider === "google") {
|
|
29
|
+
url = new URL("https://translation.googleapis.com/language/translate/v2");
|
|
30
|
+
url.searchParams.set("key", key);
|
|
31
|
+
body = new URLSearchParams({ q: text, source: "zh-CN", target: "en", format: "text" });
|
|
32
|
+
} else if (options.provider === "baidu") {
|
|
33
|
+
url = new URL("https://fanyi-api.baidu.com/api/trans/vip/translate");
|
|
34
|
+
const sign = createHash("md5").update(appId + text + salt + key, "utf8").digest("hex");
|
|
35
|
+
body = new URLSearchParams({ q: text, from: "zh", to: "en", appid: appId, salt, sign });
|
|
36
|
+
} else {
|
|
37
|
+
url = new URL("https://openapi.youdao.com/api");
|
|
38
|
+
const curtime = String(Math.floor(Date.now() / 1000));
|
|
39
|
+
const chars = Array.from(text);
|
|
40
|
+
const input = chars.length <= 20 ? text : chars.slice(0, 10).join("") + chars.length + chars.slice(-10).join("");
|
|
41
|
+
const sign = createHash("sha256").update(appId + input + salt + curtime + key, "utf8").digest("hex");
|
|
42
|
+
body = new URLSearchParams({ q: text, from: "zh-CHS", to: "en", appKey: appId, salt, curtime, sign, signType: "v3" });
|
|
43
|
+
}
|
|
44
|
+
let data;
|
|
45
|
+
try {
|
|
46
|
+
const response = await transport(url, {
|
|
47
|
+
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body,
|
|
48
|
+
redirect: "error", credentials: "omit", referrerPolicy: "no-referrer",
|
|
49
|
+
signal: AbortSignal.any([AbortSignal.timeout(10_000), ...(signal ? [signal] : [])]),
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) { await response.body?.cancel(); throw Error("HTTP failure"); }
|
|
52
|
+
data = await readTranslationResponse(response);
|
|
53
|
+
} catch {
|
|
54
|
+
signal?.throwIfAborted();
|
|
55
|
+
throw new TranslationError("翻译服务请求失败,请检查网络、密钥权限和额度。");
|
|
56
|
+
}
|
|
57
|
+
const result = options.provider === "google" ? data?.data?.translations?.[0]?.translatedText
|
|
58
|
+
: options.provider === "baidu" ? (!data.error_code ? data?.trans_result?.[0]?.dst : undefined)
|
|
59
|
+
: (String(data.errorCode) === "0" ? data?.translation?.[0] : undefined);
|
|
60
|
+
if (typeof result !== "string" || !result.trim())
|
|
61
|
+
throw new TranslationError("翻译服务未返回译文,请检查应用 ID、密钥、服务开通状态及额度。");
|
|
62
|
+
return result;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile, lstat } from "node:fs/promises";
|
|
3
|
+
import { loadEnvFile } from "node:process";
|
|
4
|
+
import { resolve, dirname } from "node:path";
|
|
5
|
+
import { parseConfig, parseTranslationConfig, TranslationError } from "../lib/core/index.js";
|
|
6
|
+
import { createNodeTranslator } from "./translation-provider.mjs";
|
|
7
|
+
|
|
8
|
+
/** Loopback-only bridge for a static web page. Never accepts keys from the browser. */
|
|
9
|
+
export function createTranslationServer(translation, origin, translate = createNodeTranslator(translation)) {
|
|
10
|
+
const config = parseTranslationConfig(translation);
|
|
11
|
+
return createServer(async (req, res) => {
|
|
12
|
+
res.setHeader("Cache-Control", "no-store");
|
|
13
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
14
|
+
if (req.headers.origin !== origin) { res.writeHead(403); res.end(); return; }
|
|
15
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
16
|
+
res.setHeader("Vary", "Origin");
|
|
17
|
+
if (req.url !== "/translate") { res.writeHead(404); res.end(); return; }
|
|
18
|
+
if (req.method === "OPTIONS") {
|
|
19
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
20
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
21
|
+
if (req.headers["access-control-request-private-network"] === "true")
|
|
22
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
23
|
+
res.writeHead(204); res.end(); return;
|
|
24
|
+
}
|
|
25
|
+
if (req.method !== "POST" || !/^application\/json(?:;|$)/i.test(req.headers["content-type"] ?? "")) {
|
|
26
|
+
res.writeHead(415); res.end(); return;
|
|
27
|
+
}
|
|
28
|
+
const abort = new AbortController();
|
|
29
|
+
res.on("close", () => { if (!res.writableEnded) abort.abort(); });
|
|
30
|
+
const reply = (status, data) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); };
|
|
31
|
+
const chunks = [];
|
|
32
|
+
try {
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
for await (const chunk of req) {
|
|
35
|
+
bytes += chunk.length;
|
|
36
|
+
if (bytes > 4096) { reply(413, { error: "Request too large" }); return; }
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
}
|
|
39
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
40
|
+
if (typeof value.text !== "string" || !value.text || Buffer.byteLength(value.text) > 500 || value.provider !== config.provider) {
|
|
41
|
+
reply(400, { error: "Invalid group name or provider mismatch" }); return;
|
|
42
|
+
}
|
|
43
|
+
const translatedText = await translate(value.text, abort.signal);
|
|
44
|
+
if (!abort.signal.aborted) reply(200, { translatedText });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (abort.signal.aborted) return;
|
|
47
|
+
reply(502, { error: error instanceof TranslationError ? error.message : "Translation failed" });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runTranslationServer(args) {
|
|
53
|
+
if (args.includes("--help")) {
|
|
54
|
+
console.log("chainspec translate-server --config chainspec.config.json [--env-file .env] [--port 4190] [--origin https://batype.github.io]\nBinds only 127.0.0.1. Real API keys stay in the local environment.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let configPath, envPath, port = 4190, origin = "https://batype.github.io";
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const flag = args[i], value = args[++i];
|
|
60
|
+
if (!value || !["--config", "--env-file", "--port", "--origin"].includes(flag)) throw Error("Use translate-server --config FILE [--env-file .env] [--port 4190] [--origin https://batype.github.io]");
|
|
61
|
+
if (flag === "--config") configPath = resolve(value);
|
|
62
|
+
else if (flag === "--env-file") envPath = resolve(value);
|
|
63
|
+
else if (flag === "--port") port = Number(value);
|
|
64
|
+
else origin = value;
|
|
65
|
+
}
|
|
66
|
+
if (!configPath) throw Error("translate-server requires --config");
|
|
67
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw Error("Port must be 1024–65535");
|
|
68
|
+
const url = new URL(origin);
|
|
69
|
+
if (url.origin !== origin || !["http:", "https:"].includes(url.protocol) || url.username || url.password) throw Error("--origin must be a single HTTP(S) origin without a path");
|
|
70
|
+
const stat = await lstat(configPath);
|
|
71
|
+
if (!stat.isFile() || stat.size > 8 * 1024 * 1024) throw Error("Invalid config file");
|
|
72
|
+
const config = parseConfig(await readFile(configPath, "utf8"));
|
|
73
|
+
if (envPath) loadEnvFile(envPath);
|
|
74
|
+
else {
|
|
75
|
+
try { loadEnvFile(resolve(dirname(configPath), ".env")); }
|
|
76
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
77
|
+
}
|
|
78
|
+
const server = createTranslationServer(config.translation ?? { provider: "mymemory" }, origin);
|
|
79
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); });
|
|
80
|
+
console.log(`Translation proxy: http://127.0.0.1:${port}/translate\nAllowed origin: ${origin}\nProvider: ${config.translation?.provider ?? "mymemory"}\nKeys are read only from local environment variables. Press Ctrl+C to stop.`);
|
|
81
|
+
return server;
|
|
82
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"input": "./openapi.json",
|
|
4
|
+
"output": "./src/api",
|
|
5
|
+
"namespace": "main",
|
|
6
|
+
"groupBy": "tag",
|
|
7
|
+
"hooks": true,
|
|
8
|
+
"translateNames": true,
|
|
9
|
+
"translation": {
|
|
10
|
+
"provider": "baidu",
|
|
11
|
+
"appIdEnv": "BAIDU_TRANSLATE_APP_ID",
|
|
12
|
+
"apiKeyEnv": "BAIDU_TRANSLATE_SECRET_KEY",
|
|
13
|
+
"proxyUrl": "http://127.0.0.1:4190/translate"
|
|
14
|
+
},
|
|
15
|
+
"controllerNames": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copy this file to .env next to chainspec.config.json, then fill only the chosen provider.
|
|
2
|
+
# Google Cloud Translation Basic v2
|
|
3
|
+
GOOGLE_TRANSLATE_API_KEY=
|
|
4
|
+
|
|
5
|
+
# Baidu General Translation
|
|
6
|
+
BAIDU_TRANSLATE_APP_ID=
|
|
7
|
+
BAIDU_TRANSLATE_SECRET_KEY=
|
|
8
|
+
|
|
9
|
+
# Youdao Text Translation v3 authentication
|
|
10
|
+
YOUDAO_TRANSLATE_APP_KEY=
|
|
11
|
+
YOUDAO_TRANSLATE_SECRET_KEY=
|
package/lib/core/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GeneratorOptions } from "./model.js";
|
|
1
|
+
import type { GeneratorOptions, TranslationConfig } from "./model.js";
|
|
2
2
|
/** Shared by the web editor and CLI. Paths resolve relative to the config file. */
|
|
3
3
|
export type ChainSpecConfig = GeneratorOptions & {
|
|
4
4
|
version: 1;
|
|
@@ -7,4 +7,7 @@ export type ChainSpecConfig = GeneratorOptions & {
|
|
|
7
7
|
};
|
|
8
8
|
export declare const DEFAULT_CONFIG: ChainSpecConfig;
|
|
9
9
|
export declare function parseConfig(input: string | unknown): ChainSpecConfig;
|
|
10
|
+
export declare const TRANSLATION_DEFAULTS: Record<TranslationConfig["provider"], TranslationConfig>;
|
|
11
|
+
export declare function parseTranslationConfig(value: unknown): TranslationConfig;
|
|
12
|
+
export declare function parseControllerNames(input: unknown): Record<string, string>;
|
|
10
13
|
export declare function documentURL(input: string): URL;
|
package/lib/core/config.js
CHANGED
|
@@ -6,10 +6,12 @@ export const DEFAULT_CONFIG = {
|
|
|
6
6
|
groupBy: "path",
|
|
7
7
|
hooks: true,
|
|
8
8
|
controllerNames: {},
|
|
9
|
+
translateNames: true,
|
|
10
|
+
translation: { provider: "mymemory" },
|
|
9
11
|
};
|
|
10
12
|
const keys = new Set([
|
|
11
13
|
"version", "input", "output", "namespace", "groupBy", "stripPrefix",
|
|
12
|
-
"controllerNames", "baseUrl", "hooks",
|
|
14
|
+
"controllerNames", "baseUrl", "hooks", "translateNames", "translation",
|
|
13
15
|
]);
|
|
14
16
|
export function parseConfig(input) {
|
|
15
17
|
let value = input;
|
|
@@ -40,19 +42,68 @@ export function parseConfig(input) {
|
|
|
40
42
|
throw new Error("groupBy 必须为 path 或 tag");
|
|
41
43
|
if (value.hooks !== undefined && typeof value.hooks !== "boolean")
|
|
42
44
|
throw new Error("hooks 必须为布尔值");
|
|
45
|
+
if (value.translateNames !== undefined && typeof value.translateNames !== "boolean")
|
|
46
|
+
throw new Error("translateNames 必须为布尔值");
|
|
47
|
+
if (value.translation !== undefined)
|
|
48
|
+
parseTranslationConfig(value.translation);
|
|
43
49
|
if (value.stripPrefix && !value.stripPrefix.startsWith("/"))
|
|
44
50
|
throw new Error("stripPrefix 必须以 / 开头");
|
|
45
51
|
if (value.controllerNames !== undefined) {
|
|
46
|
-
|
|
47
|
-
throw new Error("controllerNames 必须为原分组到英文名称的对象");
|
|
48
|
-
for (const alias of Object.values(value.controllerNames))
|
|
49
|
-
if (typeof alias !== "string" || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(alias))
|
|
50
|
-
throw new Error("controllerNames 的值必须为英文标识符,例如 sms");
|
|
52
|
+
parseControllerNames(value.controllerNames);
|
|
51
53
|
}
|
|
52
54
|
if (/^[a-z][a-z\d+.-]*:/i.test(value.input))
|
|
53
55
|
documentURL(value.input);
|
|
54
56
|
return { ...value, controllerNames: { ...value.controllerNames } };
|
|
55
57
|
}
|
|
58
|
+
export const TRANSLATION_DEFAULTS = {
|
|
59
|
+
mymemory: { provider: "mymemory" },
|
|
60
|
+
google: { provider: "google", apiKeyEnv: "GOOGLE_TRANSLATE_API_KEY" },
|
|
61
|
+
baidu: { provider: "baidu", appIdEnv: "BAIDU_TRANSLATE_APP_ID", apiKeyEnv: "BAIDU_TRANSLATE_SECRET_KEY" },
|
|
62
|
+
youdao: { provider: "youdao", appIdEnv: "YOUDAO_TRANSLATE_APP_KEY", apiKeyEnv: "YOUDAO_TRANSLATE_SECRET_KEY" },
|
|
63
|
+
};
|
|
64
|
+
export function parseTranslationConfig(value) {
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
66
|
+
throw new Error("translation 必须为对象");
|
|
67
|
+
const v = value;
|
|
68
|
+
for (const key of Object.keys(v))
|
|
69
|
+
if (!["provider", "apiKeyEnv", "appIdEnv", "proxyUrl"].includes(key))
|
|
70
|
+
throw new Error("翻译配置仅保存 provider、环境变量名和 proxyUrl;真实密钥请放入环境变量");
|
|
71
|
+
if (typeof v.provider !== "string" || !Object.hasOwn(TRANSLATION_DEFAULTS, v.provider))
|
|
72
|
+
throw new Error("翻译服务需为 mymemory、google、baidu 或 youdao");
|
|
73
|
+
for (const key of ["apiKeyEnv", "appIdEnv"])
|
|
74
|
+
if (v[key] !== undefined && (typeof v[key] !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(v[key])))
|
|
75
|
+
throw new Error(`${key} 需为环境变量名称`);
|
|
76
|
+
if (v.proxyUrl !== undefined) {
|
|
77
|
+
if (typeof v.proxyUrl !== "string")
|
|
78
|
+
throw new Error("proxyUrl 需为 HTTP(S) 地址");
|
|
79
|
+
documentURL(v.proxyUrl);
|
|
80
|
+
}
|
|
81
|
+
return { ...TRANSLATION_DEFAULTS[v.provider], ...v };
|
|
82
|
+
}
|
|
83
|
+
export function parseControllerNames(input) {
|
|
84
|
+
if (typeof input === "string") {
|
|
85
|
+
if (new TextEncoder().encode(input).length > 8 * 1024 * 1024)
|
|
86
|
+
throw new Error("映射文件最大 8 MiB");
|
|
87
|
+
try {
|
|
88
|
+
input = JSON.parse(input);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error("分组映射必须是有效 JSON 对象");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
95
|
+
throw new Error("controllerNames 必须为原分组到英文名称的对象");
|
|
96
|
+
const entries = Object.entries(input);
|
|
97
|
+
if (entries.length > 5000)
|
|
98
|
+
throw new Error("最多 5000 条分组映射");
|
|
99
|
+
for (const [tag, alias] of entries) {
|
|
100
|
+
if (!tag.trim())
|
|
101
|
+
throw new Error("请填写映射的原分组名称");
|
|
102
|
+
if (typeof alias !== "string" || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(alias))
|
|
103
|
+
throw new Error("controllerNames 的值必须为英文标识符,例如 sms");
|
|
104
|
+
}
|
|
105
|
+
return Object.fromEntries(entries);
|
|
106
|
+
}
|
|
56
107
|
export function documentURL(input) {
|
|
57
108
|
let url;
|
|
58
109
|
try {
|
package/lib/core/index.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { GeneratorOptions, GeneratedProject } from "./model.js";
|
|
2
|
-
|
|
2
|
+
import { type TranslationControls } from "./translate.js";
|
|
3
|
+
export type { TranslationConfig, GeneratorOptions, GeneratedProject, Diagnostic, Operation, } from "./model.js";
|
|
3
4
|
export { MAX_BYTES, MAX_OPERATIONS } from "./model.js";
|
|
4
|
-
export { parseConfig, DEFAULT_CONFIG } from "./config.js";
|
|
5
|
+
export { parseConfig, parseControllerNames, parseTranslationConfig, TRANSLATION_DEFAULTS, DEFAULT_CONFIG } from "./config.js";
|
|
6
|
+
export { resolveControllerNames, translateWithMyMemory, englishControllerName, TranslationError, readTranslationResponse } from "./translate.js";
|
|
7
|
+
export type { TranslationControls } from "./translate.js";
|
|
5
8
|
export type { ChainSpecConfig } from "./config.js";
|
|
6
9
|
export { fetchDocument } from "./source.js";
|
|
7
|
-
export declare const VERSION = "2.
|
|
10
|
+
export declare const VERSION = "2.2.0";
|
|
8
11
|
export declare function generateSDK(input: string | Record<string, any>, options?: GeneratorOptions): GeneratedProject;
|
|
12
|
+
/** Async opt-in API; the synchronous generateSDK always remains offline. */
|
|
13
|
+
export declare function generateSDKAsync(input: string | Record<string, any>, options?: GeneratorOptions, controls?: TranslationControls): Promise<GeneratedProject>;
|
package/lib/core/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { parseDocument, normalize } from "./parse.js";
|
|
2
2
|
import { createSchemaWriter } from "./schema.js";
|
|
3
3
|
import { RUNTIME_SOURCE, HOOKS_SOURCE, RUNTIME_LICENSE } from "./templates.js";
|
|
4
|
+
import { resolveControllerNames } from "./translate.js";
|
|
4
5
|
export { MAX_BYTES, MAX_OPERATIONS } from "./model.js";
|
|
5
|
-
export { parseConfig, DEFAULT_CONFIG } from "./config.js";
|
|
6
|
+
export { parseConfig, parseControllerNames, parseTranslationConfig, TRANSLATION_DEFAULTS, DEFAULT_CONFIG } from "./config.js";
|
|
7
|
+
export { resolveControllerNames, translateWithMyMemory, englishControllerName, TranslationError, readTranslationResponse } from "./translate.js";
|
|
6
8
|
export { fetchDocument } from "./source.js";
|
|
7
|
-
export const VERSION = "2.
|
|
9
|
+
export const VERSION = "2.2.0";
|
|
8
10
|
const q = JSON.stringify;
|
|
9
11
|
const banner = "// Generated by ChainSpec. Do not edit; regenerate from your OpenAPI document.\n";
|
|
10
12
|
export function generateSDK(input, options = {}) {
|
|
@@ -103,6 +105,12 @@ export function generateSDK(input, options = {}) {
|
|
|
103
105
|
"client.ts": client(false),
|
|
104
106
|
"index.ts": `export {createAPI,type API} from './client.js';\nexport {ApiError,defaultRequest} from './runtime.js';\nexport type {ClientConfig,RequestContext,TransportResponse,RequestAdapter,RequestOptions,Endpoint} from './runtime.js';\nexport type {Schemas,InputSchemas,OutputSchemas,Operations} from './types.js';\n`,
|
|
105
107
|
};
|
|
108
|
+
// Cache only supplied/successfully translated names, never temporary path fallbacks.
|
|
109
|
+
const controllerNames = { ...options.controllerNames };
|
|
110
|
+
for (const op of operations)
|
|
111
|
+
if (Object.hasOwn(controllerNames, op.originalController))
|
|
112
|
+
controllerNames[op.originalController] = op.controller;
|
|
113
|
+
files["controller-names.json"] = JSON.stringify(controllerNames, null, 2);
|
|
106
114
|
if (options.hooks !== false) {
|
|
107
115
|
files["hooks.ts"] = HOOKS_SOURCE;
|
|
108
116
|
files["react.ts"] =
|
|
@@ -126,7 +134,7 @@ export function generateSDK(input, options = {}) {
|
|
|
126
134
|
const first = operations.find((o) => o.method === "GET") ?? operations[0];
|
|
127
135
|
const access = `API.${namespace}[${q(first.controller)}][${q(first.name)}]`;
|
|
128
136
|
files["README.md"] =
|
|
129
|
-
`# Generated SDK\n\nGenerated locally with ChainSpec ${VERSION}. Copy these files into your TypeScript project.
|
|
137
|
+
`# Generated SDK\n\nGenerated locally with ChainSpec ${VERSION}. Copy these files into your TypeScript project. The generator never evaluates SDK code or sends business API requests. Optional translation only sends missing group names.\n\n## Setup\n\nNode22+ or a browser with Fetch, Headers, FormData, Blob. TypeScript moduleResolution Bundler or NodeNext; .js imports resolve to the corresponding .ts sources.\n\nPlain SDK: import {createAPI} from './index.js'. No React/SWR dependency.\n\n${options.hooks === false ? "" : "React SDK: npm install swr@^2.5.1 react@^19.3.1, then import {createReactAPI} from './react.js'. Use the returned API as a module-level singleton (or memoize it per Provider/SSR request). Do not create clients on every render.\n\n"}## Calls\n\n\`\`\`ts\nimport {createAPI} from './index.js';\nconst API=createAPI({baseUrl:${q(baseUrl)},cacheScope:()=> 'tenant:user:auth-revision'});\n// Inspect generated Operations params/body before calling:\n// ${access}.request(params${first.body ? ", body" : ""}, options);\n\`\`\`\n\n.request() returns the documented response body. The first params object contains path/query/cookies. With a documented request body: request(params, body, options). Without one: request(params?, options). Headers are options.headers (standard HeadersInit) and may also be set globally or by beforeRequest. Empty params are {} when a body is present. Operations exposes params/body/headers/output types. Required arguments are required in TypeScript and checked for presence by the default runtime. Schema validation is NOT performed. Missing response schemas are unknown. JS numbers can lose int64 precision: ask your server for strings if required.\n\nGET/HEAD .useRequest(params, {enabled,headers,signal,swr}) reads with SWR; null params or enabled:false pauses it. If a query operation documents a body, use the same three positions as request. Other methods .useRequest(params, body, {headers,signal,swr}) (also .useMutation) bind params and an optional body, never send automatically. trigger() uses the bound body; trigger(body) supplies or overrides it. If a required body was not bound, TypeScript requires trigger(body). For operations without a body use .useRequest(params, options) then trigger(). Trigger options, such as throwOnError, are its second argument. Hook options are flat: headers/signal/interceptors are alongside swr, not under request. Hooks must be called unconditionally at React component/custom-hook top level. HEAD returns no body in Fetch.\n\nCache keys isolate client instances, base URL, operation, inputs, request headers and cacheScope. Change cacheScope after switching users/tenants or changing authorization within one client. Do not put tokens in cacheScope. Logout should also clear your SWR Provider cache. Request interceptors do not automatically change cache keys.\n\n## Extension points\n\n- request: async context => ({data,status,headers:new Headers()}) replaces the entire Fetch transport. context includes URL, method, headers, structured body, mediaType, operation and AbortSignal. Custom adapters must preserve the declared response shape; returning an Axios response unchanged is NOT compatible (normalize response.headers).\n- beforeRequest: async context => context (or void); global handlers run before per-call handlers. Add auth headers, tracing, or cancellation here.\n- afterResponse: async (response,context) => response (or void), for successful responses only. Unwrapping changes the schema contract; do not unwrap unless the server schema already describes the unwrapped body.\n- onError: async (error,context) => void; errors are rethrown. Non-2xx status becomes ApiError with status/data/headers. Network and AbortError are preserved. No automatic retry in .request(). SWR has its own configurable retries.\n\nSupported query serialization: form (arrays and flat objects), spaceDelimited, pipeDelimited, flat deepObject. Path/header simple serialization; header objects and nested query objects need a custom serializer. Cookie parameters require a custom Node request adapter (Fetch in browsers cannot set Cookie headers). JSON, flat multipart and URL-encoded bodies supported; nested forms need a custom adapter. JSON-schema constraints, auth schemes, oneOf exclusivity, server variables, parameter serialization and streaming may need application validation.\n\nThe generator supports local refs, not remote refs. Review manifest.json diagnostics, media choices and operation names. Do not add this SDK or its spec to a public repository without checking that disclosure is intended.\n`;
|
|
130
138
|
diagnostics.push({
|
|
131
139
|
level: "info",
|
|
132
140
|
code: "static-types",
|
|
@@ -145,5 +153,17 @@ export function generateSDK(input, options = {}) {
|
|
|
145
153
|
schemaCount,
|
|
146
154
|
diagnostics,
|
|
147
155
|
files,
|
|
156
|
+
controllerNames,
|
|
148
157
|
};
|
|
149
158
|
}
|
|
159
|
+
/** Async opt-in API; the synchronous generateSDK always remains offline. */
|
|
160
|
+
export async function generateSDKAsync(input, options = {}, controls = {}) {
|
|
161
|
+
const prepared = await resolveControllerNames(input, options, controls);
|
|
162
|
+
controls.signal?.throwIfAborted();
|
|
163
|
+
const project = generateSDK(input, { ...options, controllerNames: prepared.controllerNames });
|
|
164
|
+
project.diagnostics.push(...prepared.diagnostics);
|
|
165
|
+
const manifest = JSON.parse(project.files["manifest.json"]);
|
|
166
|
+
manifest.diagnostics = project.diagnostics;
|
|
167
|
+
project.files["manifest.json"] = JSON.stringify(manifest, null, 2);
|
|
168
|
+
return project;
|
|
169
|
+
}
|
package/lib/core/model.d.ts
CHANGED
|
@@ -12,6 +12,15 @@ export type GeneratorOptions = {
|
|
|
12
12
|
controllerNames?: Record<string, string>;
|
|
13
13
|
baseUrl?: string;
|
|
14
14
|
hooks?: boolean;
|
|
15
|
+
translateNames?: boolean;
|
|
16
|
+
translation?: TranslationConfig;
|
|
17
|
+
};
|
|
18
|
+
export type TranslationConfig = {
|
|
19
|
+
provider: "mymemory" | "google" | "baidu" | "youdao";
|
|
20
|
+
apiKeyEnv?: string;
|
|
21
|
+
appIdEnv?: string;
|
|
22
|
+
/** Browser-only proxy; CLI talks directly to the configured official provider. */
|
|
23
|
+
proxyUrl?: string;
|
|
15
24
|
};
|
|
16
25
|
export type Parameter = {
|
|
17
26
|
name: string;
|
|
@@ -26,6 +35,7 @@ export type Operation = {
|
|
|
26
35
|
method: string;
|
|
27
36
|
path: string;
|
|
28
37
|
controller: string;
|
|
38
|
+
originalController: string;
|
|
29
39
|
name: string;
|
|
30
40
|
originalId?: string;
|
|
31
41
|
tags: string[];
|
|
@@ -51,6 +61,7 @@ export type GeneratedProject = {
|
|
|
51
61
|
schemaCount: number;
|
|
52
62
|
diagnostics: Diagnostic[];
|
|
53
63
|
files: Record<string, string>;
|
|
64
|
+
controllerNames: Record<string, string>;
|
|
54
65
|
};
|
|
55
66
|
export declare const MAX_BYTES: number;
|
|
56
67
|
export declare const MAX_OPERATIONS = 5000;
|
package/lib/core/parse.js
CHANGED
|
@@ -93,7 +93,8 @@ export function normalize(doc, options, diagnostics) {
|
|
|
93
93
|
"default";
|
|
94
94
|
const originalGroup = op["x-sdk-controller"] ??
|
|
95
95
|
(options.groupBy === "tag" ? (tags[0] ?? pathGroup) : pathGroup);
|
|
96
|
-
const alias = options.controllerNames
|
|
96
|
+
const alias = Object.hasOwn(options.controllerNames ?? {}, originalGroup)
|
|
97
|
+
? options.controllerNames[originalGroup] : undefined;
|
|
97
98
|
if (alias !== undefined && !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(alias))
|
|
98
99
|
throw new Error(`分组「${originalGroup}」的别名需为英文标识符`);
|
|
99
100
|
let controller = assignedGroups.get(originalGroup) ?? nameOf(alias ?? (/^[\x00-\x7F]+$/.test(originalGroup) ? originalGroup : pathGroup), "group_" + hash(originalGroup));
|
|
@@ -324,6 +325,7 @@ export function normalize(doc, options, diagnostics) {
|
|
|
324
325
|
method: method.toUpperCase(),
|
|
325
326
|
path,
|
|
326
327
|
controller,
|
|
328
|
+
originalController: originalGroup,
|
|
327
329
|
name,
|
|
328
330
|
originalId,
|
|
329
331
|
tags,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Diagnostic, GeneratorOptions, Json } from "./model.js";
|
|
2
|
+
export type TranslationControls = {
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
/** Optional adapter for self-hosted translation or offline tests. */
|
|
5
|
+
translate?: (name: string, signal?: AbortSignal) => Promise<string>;
|
|
6
|
+
onProgress?: (progress: {
|
|
7
|
+
current: number;
|
|
8
|
+
total: number;
|
|
9
|
+
}) => void;
|
|
10
|
+
};
|
|
11
|
+
/** Only these locally authored messages may be shown; provider errors may contain credentials. */
|
|
12
|
+
export declare class TranslationError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export declare function englishControllerName(text: string): string;
|
|
15
|
+
/** GET only: never contributes source text to a translation memory. */
|
|
16
|
+
export declare function translateWithMyMemory(name: string, signal?: AbortSignal): Promise<string>;
|
|
17
|
+
export declare function translateViaProxy(name: string, options: GeneratorOptions, signal?: AbortSignal): Promise<string>;
|
|
18
|
+
export declare function readTranslationResponse(response: Response): Promise<any>;
|
|
19
|
+
export declare function resolveControllerNames(input: string | Json, options?: GeneratorOptions, controls?: TranslationControls): Promise<{
|
|
20
|
+
controllerNames: Record<string, string>;
|
|
21
|
+
diagnostics: Diagnostic[];
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { normalize, parseDocument } from "./parse.js";
|
|
2
|
+
import { parseControllerNames, parseTranslationConfig } from "./config.js";
|
|
3
|
+
/** Only these locally authored messages may be shown; provider errors may contain credentials. */
|
|
4
|
+
export class TranslationError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export function englishControllerName(text) {
|
|
7
|
+
const decoded = text.replace(/&(amp|quot|apos|lt|gt|nbsp|#39);/g, (entity) => ({ "&": "&", """: '"', "'": "'", "'": "'", "<": "<", ">": ">", " ": " " })[entity]);
|
|
8
|
+
if (decoded.length > 200 || /[^\x20-\x7E]/.test(decoded) || !/[A-Za-z]/.test(decoded))
|
|
9
|
+
throw new Error("翻译结果不是有效英文名称");
|
|
10
|
+
const words = decoded.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
11
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
12
|
+
let name = words.map((word, i) => {
|
|
13
|
+
const lower = word.toLowerCase();
|
|
14
|
+
return i ? lower[0].toUpperCase() + lower.slice(1) : lower;
|
|
15
|
+
}).join("");
|
|
16
|
+
if (!/^[A-Za-z]/.test(name))
|
|
17
|
+
name = "_" + name;
|
|
18
|
+
if (["constructor", "prototype", "__proto__", "then"].includes(name))
|
|
19
|
+
name += "Api";
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
/** GET only: never contributes source text to a translation memory. */
|
|
23
|
+
export async function translateWithMyMemory(name, signal) {
|
|
24
|
+
if (new TextEncoder().encode(name).length > 500)
|
|
25
|
+
throw new Error("单个分组名称超过免费接口 500 字节限制");
|
|
26
|
+
const url = new URL("https://api.mymemory.translated.net/get");
|
|
27
|
+
url.searchParams.set("q", name);
|
|
28
|
+
url.searchParams.set("langpair", "zh-CN|en");
|
|
29
|
+
const response = await fetch(url, {
|
|
30
|
+
credentials: "omit", referrerPolicy: "no-referrer",
|
|
31
|
+
signal: AbortSignal.any([AbortSignal.timeout(10_000), ...(signal ? [signal] : [])]),
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
await response.body?.cancel();
|
|
35
|
+
throw new Error("免费翻译服务暂不可用或已限流");
|
|
36
|
+
}
|
|
37
|
+
if (!response.body)
|
|
38
|
+
throw new Error("翻译服务返回为空");
|
|
39
|
+
const reader = response.body.getReader();
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let bytes = 0;
|
|
42
|
+
try {
|
|
43
|
+
while (true) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
break;
|
|
47
|
+
bytes += value.byteLength;
|
|
48
|
+
if (bytes > 128 * 1024)
|
|
49
|
+
throw new Error("翻译响应过大");
|
|
50
|
+
chunks.push(value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await reader.cancel().catch(() => { });
|
|
55
|
+
reader.releaseLock();
|
|
56
|
+
}
|
|
57
|
+
const joined = new Uint8Array(bytes);
|
|
58
|
+
let offset = 0;
|
|
59
|
+
for (const chunk of chunks) {
|
|
60
|
+
joined.set(chunk, offset);
|
|
61
|
+
offset += chunk.length;
|
|
62
|
+
}
|
|
63
|
+
const data = JSON.parse(new TextDecoder().decode(joined));
|
|
64
|
+
if (data.quotaFinished || Number(data.responseStatus) !== 200 || typeof data.responseData?.translatedText !== "string")
|
|
65
|
+
throw new Error("免费翻译额度已用完或服务暂不可用");
|
|
66
|
+
return data.responseData.translatedText;
|
|
67
|
+
}
|
|
68
|
+
export async function translateViaProxy(name, options, signal) {
|
|
69
|
+
const config = parseTranslationConfig(options.translation ?? { provider: "mymemory" });
|
|
70
|
+
if (!config.proxyUrl)
|
|
71
|
+
throw new TranslationError("该翻译服务需要配置浏览器代理地址;可先运行 chainspec translate-server,或直接使用 CLI 生成。");
|
|
72
|
+
const response = await fetch(config.proxyUrl, {
|
|
73
|
+
method: "POST", credentials: "omit", referrerPolicy: "no-referrer", redirect: "error",
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ text: name, provider: config.provider }),
|
|
76
|
+
signal: AbortSignal.any([AbortSignal.timeout(15_000), ...(signal ? [signal] : [])]),
|
|
77
|
+
});
|
|
78
|
+
const data = await readTranslationResponse(response);
|
|
79
|
+
if (!response.ok || typeof data.translatedText !== "string")
|
|
80
|
+
throw new TranslationError("翻译代理调用失败,请检查代理服务、环境变量密钥和所选服务是否一致。");
|
|
81
|
+
return data.translatedText;
|
|
82
|
+
}
|
|
83
|
+
export async function readTranslationResponse(response) {
|
|
84
|
+
if (!response.body)
|
|
85
|
+
throw new Error("Empty translation response");
|
|
86
|
+
const reader = response.body.getReader();
|
|
87
|
+
const decoder = new TextDecoder();
|
|
88
|
+
let bytes = 0, text = "";
|
|
89
|
+
try {
|
|
90
|
+
while (true) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done)
|
|
93
|
+
break;
|
|
94
|
+
bytes += value.byteLength;
|
|
95
|
+
if (bytes > 128 * 1024)
|
|
96
|
+
throw new Error("Translation response too large");
|
|
97
|
+
text += decoder.decode(value, { stream: true });
|
|
98
|
+
}
|
|
99
|
+
text += decoder.decode();
|
|
100
|
+
return JSON.parse(text);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await reader.cancel().catch(() => { });
|
|
104
|
+
reader.releaseLock();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function resolveControllerNames(input, options = {}, controls = {}) {
|
|
108
|
+
const controllerNames = parseControllerNames(options.controllerNames ?? {});
|
|
109
|
+
const diagnostics = [];
|
|
110
|
+
if (options.translateNames === false)
|
|
111
|
+
return { controllerNames, diagnostics };
|
|
112
|
+
const operations = normalize(parseDocument(input), options, []).operations;
|
|
113
|
+
const missing = [...new Set(operations.map((op) => op.originalController))]
|
|
114
|
+
.filter((name) => /\p{Script=Han}/u.test(name) && !Object.hasOwn(controllerNames, name)).sort();
|
|
115
|
+
const config = parseTranslationConfig(options.translation ?? { provider: "mymemory" });
|
|
116
|
+
const translate = controls.translate ?? (config.provider === "mymemory"
|
|
117
|
+
? translateWithMyMemory : (name, signal) => translateViaProxy(name, options, signal));
|
|
118
|
+
let characters = 0;
|
|
119
|
+
for (let i = 0; i < missing.length; i++) {
|
|
120
|
+
controls.signal?.throwIfAborted();
|
|
121
|
+
const name = missing[i];
|
|
122
|
+
if (i >= 100 || characters + name.length > 5000) {
|
|
123
|
+
diagnostics.push({ level: "warning", code: "translation-limit", message: "本次自动翻译达到分组/字符上限,剩余分组按路径命名;成功结果已缓存。" });
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
controls.onProgress?.({ current: i + 1, total: missing.length });
|
|
127
|
+
if (new TextEncoder().encode(name).length > 500) {
|
|
128
|
+
diagnostics.push({ level: "warning", code: "translation-name-too-long", message: "分组名称超过自动翻译长度限制,已按路径命名。", location: name });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
characters += name.length;
|
|
132
|
+
let translated;
|
|
133
|
+
try {
|
|
134
|
+
translated = await translate(name, controls.signal);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
controls.signal?.throwIfAborted();
|
|
138
|
+
diagnostics.push({ level: "warning", code: "translation-unavailable", message: error instanceof TranslationError
|
|
139
|
+
? error.message : "翻译服务不可用、鉴权失败或额度用完;已保留成功缓存,其余分组按路径命名。", location: name });
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
controllerNames[name] = englishControllerName(translated);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
diagnostics.push({ level: "warning", code: "translation-invalid", message: "服务未返回有效英文名称,已按路径命名;下次可以重试。", location: name });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { controllerNames, diagnostics };
|
|
150
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@batype/chainspec",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Generate typed chainable TypeScript SDKs and SWR hooks from OpenAPI JSON, locally.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./lib/core/index.d.ts",
|
|
13
13
|
"import": "./lib/core/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./node": {
|
|
16
|
+
"types": "./bin/translation-provider.d.ts",
|
|
17
|
+
"import": "./bin/translation-provider.mjs"
|
|
14
18
|
}
|
|
15
19
|
},
|
|
16
20
|
"bin": {
|
|
@@ -21,7 +25,8 @@
|
|
|
21
25
|
"bin/",
|
|
22
26
|
"README.md",
|
|
23
27
|
"LICENSE",
|
|
24
|
-
"THIRD-PARTY-LICENSES.txt"
|
|
28
|
+
"THIRD-PARTY-LICENSES.txt",
|
|
29
|
+
"examples/"
|
|
25
30
|
],
|
|
26
31
|
"scripts": {
|
|
27
32
|
"templates": "node scripts/embed-templates.mjs",
|