feat: fun-cli v1.0.0 — gen/run/docs 三命令,fun 框架零改动
- fun gen [ts|go]:AST 扫描 BindService* 调用,临时生成 go test 辅助采集, 经 fun v1.3.7 公开 API 生成,产物与内置 GenCode 字节级一致(回归测试锁定) - fun run:热更新开发循环,先编译成功再切换,失败保留旧进程 - fun docs:浅色标准文档站——参数表/State 添加字段/请求 JSON 可编辑/ 响应格式化/NDJSON 流式逐行展示,反向代理 /cell 同源无 CORS - 元数据:TS 产物两遍解析重建(枚举/可空/流式工厂/嵌套引用全支持) - 纯标准库实现,零第三方依赖
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
fun-cli
|
||||||
|
*.test
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# fun-cli
|
||||||
|
|
||||||
|
[fun](https://git.cyi.cc/chiyi/fun) 框架的配套命令行工具。**不修改 fun 框架任何一行**,
|
||||||
|
兼容 v1.3.7+;纯 Go 标准库实现,`go install` 即用。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/cyi-cc/fun-cli@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作原理(为什么 fun 可以零改动)
|
||||||
|
|
||||||
|
fun 的服务注册全在 `BindService`/`BindServiceForGen` 调用里。fun-cli 用 AST 扫描定位
|
||||||
|
这些调用(支持 `&pkg.Svc{}`、同包 `&Svc{}`、`for _, x := range []any{...}` 循环注册),
|
||||||
|
在被调用包目录**临时生成一个 `go test` 辅助文件**——测试文件天然能访问包内类型
|
||||||
|
(服务定义在 `package main` 也不怕)——执行后立即删除。生成由 fun 公开的
|
||||||
|
`BindServiceForGen + SetOutput + GenCode` 完成,产物与内置生成器完全一致。
|
||||||
|
|
||||||
|
## 三个命令
|
||||||
|
|
||||||
|
### fun gen —— 生成客户端代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fun gen # Go + TS → ./gen
|
||||||
|
fun gen ts # 只生成 TypeScript
|
||||||
|
fun gen go # 只生成 Go
|
||||||
|
fun gen ts -o ./frontend/src/api
|
||||||
|
fun gen -from meta.json # 直接渲染元数据 JSON(CI 友好)
|
||||||
|
```
|
||||||
|
|
||||||
|
### fun run —— 热更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fun run # 改 .go/go.mod/go.sum 自动重编译重启
|
||||||
|
fun run -p ./cmd/server
|
||||||
|
fun run -- -config dev.yaml # 透传程序参数
|
||||||
|
```
|
||||||
|
|
||||||
|
先编译成功再切换进程;编译失败保留旧进程。Ctrl+C 连子进程一起退出。
|
||||||
|
|
||||||
|
### fun docs —— API 文档站
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fun docs # 自动 go run . 拉起后端
|
||||||
|
fun docs -upstream http://127.0.0.1:9000 # 指定后端地址
|
||||||
|
fun docs -no-run # 后端已在别处运行
|
||||||
|
```
|
||||||
|
|
||||||
|
- 浅色标准文档风格:侧栏服务/方法树(stream/void 徽标)+ 搜索
|
||||||
|
- 参数表:字段/类型/示例值/说明;枚举下拉、嵌套 DTO 分层、可空标注
|
||||||
|
- **请求**:表单实时生成 JSON,可直接编辑后发送
|
||||||
|
- **State**:"添加字段"式键值编辑(存 localStorage),值注入请求 `state` 对象——
|
||||||
|
键以后端 Guard 读取为准,前端只负责填值
|
||||||
|
- **响应**:格式化 JSON;NDJSON 流式方法逐行实时追加
|
||||||
|
- 文档站反向代理 `/cell` 到后端,同源无 CORS 问题
|
||||||
|
|
||||||
|
## 元数据 JSON
|
||||||
|
|
||||||
|
`fun docs -from`/`fun gen -from` 消费的元数据格式(由 TS 产物解析重建):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"services":[{"name":"userSvc","methods":[
|
||||||
|
{"name":"login","dto":{"kind":"struct","name":"loginDto","fields":[
|
||||||
|
{"name":"email","type":{"kind":"string","name":"string"}}]}},
|
||||||
|
"returns":{"kind":"string","name":"string"}}]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知边界
|
||||||
|
|
||||||
|
- fun v1.3.7 的 TS 模板对流式+DTO 方法会生成 `dto: dto:X` 的双前缀类型
|
||||||
|
(框架自身的模板怪癖)。fun-cli 的文档站解析已兼容;生成的 TS 文件如需编译,
|
||||||
|
该怪癖仍随产物保留——按"fun 零改动"约定不去修它。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./... # 渲染回归 + 解析回环 + node 门控的 TS 客户端行为测试
|
||||||
|
```
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// AST 扫描:在用户模块里找 BindService / BindServiceForGen 调用点,
|
||||||
|
// 按目录聚合——fun 框架零改动,采集全靠 fun 公开 API
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceRef struct {
|
||||||
|
Alias string // &pkg.Svc 的 pkg 别名;同包引用为空
|
||||||
|
Import string // pkg 对应的导入路径
|
||||||
|
TypeName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// modulePath 读 go.mod 的 module 行
|
||||||
|
func modulePath(root string) (string, error) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("找不到 go.mod(%s): %w", root, err)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "module ") {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("go.mod 中没有 module 声明")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findModuleRoot 从 dir 向上找 go.mod
|
||||||
|
func findModuleRoot(dir string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(abs, "go.mod")); err == nil {
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(abs)
|
||||||
|
if parent == abs {
|
||||||
|
return "", fmt.Errorf("从 %s 向上未找到 go.mod", dir)
|
||||||
|
}
|
||||||
|
abs = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var scanSkipDirs = map[string]bool{
|
||||||
|
".git": true, "vendor": true, "node_modules": true, ".fun-tmp": true, "testdata": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanBindTargets 扫描模块内全部 .go 文件的 BindService* 调用,
|
||||||
|
// 按目录聚合成待生成辅助测试的包目标(测试文件可访问包内类型,
|
||||||
|
// 服务定义在 package main 也能采集)
|
||||||
|
func scanBindTargets(root string) ([]helperTarget, error) {
|
||||||
|
byDir := map[string]*helperTarget{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
add := func(dir, pkgName string, r serviceRef) {
|
||||||
|
key := dir + "|" + r.Alias + "|" + r.Import + "|" + r.TypeName
|
||||||
|
if seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
t, ok := byDir[dir]
|
||||||
|
if !ok {
|
||||||
|
t = &helperTarget{Dir: dir, Pkg: pkgName}
|
||||||
|
byDir[dir] = t
|
||||||
|
}
|
||||||
|
t.Refs = append(t.Refs, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
if path != root && (scanSkipDirs[d.Name()] || strings.HasPrefix(d.Name(), ".")) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, perr := parser.ParseFile(fset, path, nil, 0)
|
||||||
|
if perr != nil {
|
||||||
|
return nil // 语法不过的文件(生成物/草稿)跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
// 该文件可见的导入:别名/包名 → 导入路径
|
||||||
|
imports := map[string]string{}
|
||||||
|
for _, imp := range file.Imports {
|
||||||
|
importPath, _ := strconv.Unquote(imp.Path.Value)
|
||||||
|
alias := filepath.Base(importPath)
|
||||||
|
if imp.Name != nil {
|
||||||
|
alias = imp.Name.Name
|
||||||
|
}
|
||||||
|
imports[alias] = importPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务列表变量:for _, x := range []any{&A{}, &B{}} / x := []any{...}
|
||||||
|
// (BindServiceForGen(svc) 循环注册的常见写法)
|
||||||
|
parsePtrComposite := func(e ast.Expr) (serviceRef, bool) {
|
||||||
|
u, ok := e.(*ast.UnaryExpr)
|
||||||
|
if !ok || u.Op != token.AND {
|
||||||
|
return serviceRef{}, false
|
||||||
|
}
|
||||||
|
lit, ok := u.X.(*ast.CompositeLit)
|
||||||
|
if !ok {
|
||||||
|
return serviceRef{}, false
|
||||||
|
}
|
||||||
|
switch t := lit.Type.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return serviceRef{TypeName: t.Name}, true
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
if pkg, ok := t.X.(*ast.Ident); ok {
|
||||||
|
if importPath, ok := imports[pkg.Name]; ok {
|
||||||
|
return serviceRef{Alias: pkg.Name, Import: importPath, TypeName: t.Sel.Name}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serviceRef{}, false
|
||||||
|
}
|
||||||
|
collectSlice := func(lit *ast.CompositeLit) []serviceRef {
|
||||||
|
var refs []serviceRef
|
||||||
|
for _, el := range lit.Elts {
|
||||||
|
if r, ok := parsePtrComposite(el); ok {
|
||||||
|
refs = append(refs, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
sliceVars := map[string][]serviceRef{}
|
||||||
|
var collectVars func(n ast.Node) bool
|
||||||
|
collectVars = func(n ast.Node) bool {
|
||||||
|
switch st := n.(type) {
|
||||||
|
case *ast.RangeStmt:
|
||||||
|
if v, ok := st.Value.(*ast.Ident); ok {
|
||||||
|
if lit, ok := st.X.(*ast.CompositeLit); ok {
|
||||||
|
sliceVars[v.Name] = collectSlice(lit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *ast.AssignStmt:
|
||||||
|
if len(st.Lhs) == 1 {
|
||||||
|
if id, ok := st.Lhs[0].(*ast.Ident); ok {
|
||||||
|
if lit, ok := st.Rhs[0].(*ast.CompositeLit); ok {
|
||||||
|
sliceVars[id.Name] = collectSlice(lit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ast.Inspect(file, collectVars)
|
||||||
|
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sel.Sel.Name != "BindService" && sel.Sel.Name != "BindServiceForGen" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(call.Args) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 参数形如 &pkg.Svc{} / &Svc{}
|
||||||
|
var typeExpr ast.Expr
|
||||||
|
if a, ok := call.Args[0].(*ast.UnaryExpr); ok && a.Op == token.AND {
|
||||||
|
if lit, ok := a.X.(*ast.CompositeLit); ok {
|
||||||
|
typeExpr = lit.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ident, ok := call.Args[0].(*ast.Ident); ok {
|
||||||
|
// BindServiceForGen(svc):循环/切片变量注册
|
||||||
|
for _, r := range sliceVars[ident.Name] {
|
||||||
|
add(filepath.Dir(path), file.Name.Name, r)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if typeExpr == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch t := typeExpr.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
add(filepath.Dir(path), file.Name.Name, serviceRef{TypeName: t.Name})
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
if pkg, ok := t.X.(*ast.Ident); ok {
|
||||||
|
if importPath, ok := imports[pkg.Name]; ok {
|
||||||
|
add(filepath.Dir(path), file.Name.Name, serviceRef{
|
||||||
|
Alias: pkg.Name, Import: importPath, TypeName: t.Sel.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs := make([]string, 0, len(byDir))
|
||||||
|
for d := range byDir {
|
||||||
|
dirs = append(dirs, d)
|
||||||
|
}
|
||||||
|
sort.Strings(dirs)
|
||||||
|
targets := make([]helperTarget, 0, len(dirs))
|
||||||
|
for _, d := range dirs {
|
||||||
|
targets = append(targets, *byDir[d])
|
||||||
|
}
|
||||||
|
return targets, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTypeScriptClientBehavior(t *testing.T) {
|
||||||
|
node, err := exec.LookPath("node")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("node is not installed")
|
||||||
|
}
|
||||||
|
probe := exec.Command(node, "--experimental-strip-types", "--input-type=module", "-e",
|
||||||
|
`if (typeof fetch !== "function" || typeof ReadableStream !== "function") process.exit(1)`)
|
||||||
|
if err := probe.Run(); err != nil {
|
||||||
|
t.Skip("node does not support TypeScript stripping and web streams")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const script = `
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Client } from "./client.ts";
|
||||||
|
|
||||||
|
const json = (value, status = 200) => new Response(JSON.stringify(value), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
const ndjson = body => new Response(body, {
|
||||||
|
headers: { "Content-Type": "application/x-ndjson; charset=utf-8" },
|
||||||
|
});
|
||||||
|
const client = new Client("http://example.test///");
|
||||||
|
const seen = [];
|
||||||
|
let cleanupCheck;
|
||||||
|
client.addResponseInterceptor((_service, _method, result) => {
|
||||||
|
if (cleanupCheck) {
|
||||||
|
assert.equal(cleanupCheck(), true);
|
||||||
|
cleanupCheck = undefined;
|
||||||
|
}
|
||||||
|
seen.push(result.status);
|
||||||
|
});
|
||||||
|
const expectSeen = status => assert.deepEqual(seen.splice(0), [status]);
|
||||||
|
|
||||||
|
let fetchInit;
|
||||||
|
globalThis.fetch = async (url, init) => {
|
||||||
|
fetchInit = { url, init };
|
||||||
|
return json({ status: 0, data: "ok" });
|
||||||
|
};
|
||||||
|
let result = await client.request("Svc", "fetchShape", undefined, { signal: new AbortController().signal });
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(fetchInit.url, "http://example.test/cell");
|
||||||
|
assert.equal(fetchInit.init.method, "POST");
|
||||||
|
assert.equal(fetchInit.init.headers["Content-Type"], "application/json");
|
||||||
|
assert.ok(fetchInit.init.signal);
|
||||||
|
assert.deepEqual(JSON.parse(fetchInit.init.body), { serviceName: "Svc", methodName: "fetchShape" });
|
||||||
|
expectSeen(0);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => json({ status: 2, code: 4001, msg: "business" }, 503);
|
||||||
|
result = await client.request("Svc", "business");
|
||||||
|
assert.deepEqual(result, { status: 2, code: 4001, msg: "business" });
|
||||||
|
expectSeen(2);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => json({ status: 1, msg: "framework" }, 500);
|
||||||
|
result = await client.request("Svc", "framework");
|
||||||
|
assert.deepEqual(result, { status: 1, msg: "framework" });
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response("<html>gateway timeout</html>", { status: 504 });
|
||||||
|
result = await client.request("Svc", "gatewayTimeout");
|
||||||
|
assert.equal(result.status, 5);
|
||||||
|
assert.match(result.msg, /504/);
|
||||||
|
expectSeen(5);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response("request timeout", { status: 408 });
|
||||||
|
result = await client.request("Svc", "gateway408");
|
||||||
|
assert.equal(result.status, 5);
|
||||||
|
expectSeen(5);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => json({ status: 5, msg: "server timeout" });
|
||||||
|
result = await client.request("Svc", "serverTimeout");
|
||||||
|
assert.equal(result.status, 5);
|
||||||
|
assert.equal(result.msg, "server timeout");
|
||||||
|
expectSeen(5);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response("bad gateway", { status: 502 });
|
||||||
|
result = await client.request("Svc", "gateway");
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
expectSeen(4);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response("", { status: 200 });
|
||||||
|
result = await client.request("Svc", "empty");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /Empty/);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response("not-json", { status: 200 });
|
||||||
|
result = await client.request("Svc", "invalidJson");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /Invalid JSON/);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response("<html>login</html>", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
result = await client.request("Svc", "html");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /HTML/);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => json({ data: 1 });
|
||||||
|
result = await client.request("Svc", "invalidResult");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => { throw new TypeError("DNS failed"); };
|
||||||
|
result = await client.request("Svc", "network");
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.match(result.msg, /DNS failed/);
|
||||||
|
expectSeen(4);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
pull(controller) { controller.error(new Error("body read failed")); },
|
||||||
|
}));
|
||||||
|
result = await client.request("Svc", "bodyRead");
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.match(result.msg, /body read failed/);
|
||||||
|
expectSeen(4);
|
||||||
|
|
||||||
|
const manualAbort = new AbortController();
|
||||||
|
manualAbort.abort();
|
||||||
|
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
|
||||||
|
result = await client.request("Svc", "abort", undefined, { signal: manualAbort.signal });
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.match(result.msg, /aborted/i);
|
||||||
|
expectSeen(4);
|
||||||
|
|
||||||
|
const timeoutAbort = new AbortController();
|
||||||
|
timeoutAbort.abort(new DOMException("timed out", "TimeoutError"));
|
||||||
|
result = await client.request("Svc", "timeoutSignal", undefined, { signal: timeoutAbort.signal });
|
||||||
|
assert.equal(result.status, 5);
|
||||||
|
expectSeen(5);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => { throw new DOMException("timed out", "TimeoutError"); };
|
||||||
|
result = await client.request("Svc", "timeoutError");
|
||||||
|
assert.equal(result.status, 5);
|
||||||
|
expectSeen(5);
|
||||||
|
|
||||||
|
const requestInterceptorClient = new Client("http://example.test");
|
||||||
|
let requestInterceptorSeen;
|
||||||
|
requestInterceptorClient.addRequestInterceptor((_service, _method, state, dto) => {
|
||||||
|
state.token = "abc";
|
||||||
|
requestInterceptorSeen = dto;
|
||||||
|
});
|
||||||
|
requestInterceptorClient.addResponseInterceptor((_service, _method, value) => ({ ...value, msg: "intercepted" }));
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
const payload = JSON.parse(init.body);
|
||||||
|
assert.deepEqual(payload.state, { token: "abc" });
|
||||||
|
return json({ status: 0 });
|
||||||
|
};
|
||||||
|
result = await requestInterceptorClient.request("Svc", "interceptors", { value: 1 });
|
||||||
|
assert.deepEqual(requestInterceptorSeen, { value: 1 });
|
||||||
|
assert.equal(result.msg, "intercepted");
|
||||||
|
|
||||||
|
const stateClient = new Client("http://example.test");
|
||||||
|
stateClient.setState({ shared: "global", globalOnly: "yes" });
|
||||||
|
const stateContexts = new Map();
|
||||||
|
const nativeResponses = new Map();
|
||||||
|
const payloads = new Map();
|
||||||
|
let releaseFirst;
|
||||||
|
const firstMayContinue = new Promise(resolve => { releaseFirst = resolve; });
|
||||||
|
let firstInterceptorEntered;
|
||||||
|
const firstDidEnter = new Promise(resolve => { firstInterceptorEntered = resolve; });
|
||||||
|
let firstStateReference;
|
||||||
|
stateClient.addRequestInterceptor(async (_service, method, state, dto) => {
|
||||||
|
state.interceptor = dto.id;
|
||||||
|
if (method === "first") {
|
||||||
|
firstStateReference = state;
|
||||||
|
firstInterceptorEntered();
|
||||||
|
await firstMayContinue;
|
||||||
|
}
|
||||||
|
state.completed = dto.id;
|
||||||
|
});
|
||||||
|
stateClient.addResponseInterceptor((_service, method, _value, context) => {
|
||||||
|
stateContexts.set(method, context);
|
||||||
|
});
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
const payload = JSON.parse(init.body);
|
||||||
|
payloads.set(payload.methodName, payload);
|
||||||
|
const response = json({ status: 0, data: payload.methodName });
|
||||||
|
nativeResponses.set(payload.methodName, response);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
const firstOverride = { shared: "first", requestOnly: "one" };
|
||||||
|
const firstRequest = stateClient.request("Svc", "first", { id: "one" }, { state: firstOverride });
|
||||||
|
await firstDidEnter;
|
||||||
|
firstOverride.shared = "mutated outside";
|
||||||
|
const secondRequest = stateClient.request("Svc", "second", { id: "two" }, {
|
||||||
|
state: { shared: "second", requestOnly: "two" },
|
||||||
|
});
|
||||||
|
assert.equal((await secondRequest).status, 0);
|
||||||
|
releaseFirst();
|
||||||
|
assert.equal((await firstRequest).status, 0);
|
||||||
|
assert.deepEqual(payloads.get("first").state, {
|
||||||
|
shared: "first",
|
||||||
|
globalOnly: "yes",
|
||||||
|
requestOnly: "one",
|
||||||
|
interceptor: "one",
|
||||||
|
completed: "one",
|
||||||
|
});
|
||||||
|
assert.deepEqual(payloads.get("second").state, {
|
||||||
|
shared: "second",
|
||||||
|
globalOnly: "yes",
|
||||||
|
requestOnly: "two",
|
||||||
|
interceptor: "two",
|
||||||
|
completed: "two",
|
||||||
|
});
|
||||||
|
firstStateReference.shared = "mutated after snapshot";
|
||||||
|
for (const method of ["first", "second"]) {
|
||||||
|
const context = stateContexts.get(method);
|
||||||
|
assert.deepEqual(context.requestState, payloads.get(method).state);
|
||||||
|
assert.equal(Object.isFrozen(context.requestState), true);
|
||||||
|
assert.equal(Object.isFrozen(context), true);
|
||||||
|
assert.equal(context.response, nativeResponses.get(method));
|
||||||
|
assert.ok(context.response instanceof Response);
|
||||||
|
}
|
||||||
|
assert.equal(stateContexts.get("first").requestState.shared, "first");
|
||||||
|
|
||||||
|
const throwingState = Object.defineProperty({}, "token", {
|
||||||
|
enumerable: true,
|
||||||
|
get() { throw new Error("state read failed"); },
|
||||||
|
});
|
||||||
|
const stateFailureClient = new Client("http://example.test");
|
||||||
|
let stateFailureSeen = [];
|
||||||
|
stateFailureClient.addResponseInterceptor((_service, _method, value, context) => {
|
||||||
|
stateFailureSeen.push([value.status, context.requestState]);
|
||||||
|
});
|
||||||
|
globalThis.fetch = async () => assert.fail("fetch must not run for state failure");
|
||||||
|
result = await stateFailureClient.request("Svc", "requestStateFailure", undefined, { state: throwingState });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /prepare request state/i);
|
||||||
|
result = await stateFailureClient.stream("Svc", "streamStateFailure", undefined, () => {}, { state: throwingState });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /prepare request state/i);
|
||||||
|
assert.deepEqual(stateFailureSeen, [[1, {}], [1, {}]]);
|
||||||
|
|
||||||
|
const snapshotFailureClient = new Client("http://example.test");
|
||||||
|
snapshotFailureClient.addRequestInterceptor((_service, _method, state) => {
|
||||||
|
Object.defineProperty(state, "broken", {
|
||||||
|
enumerable: true,
|
||||||
|
get() { throw new Error("snapshot read failed"); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
globalThis.fetch = async () => assert.fail("fetch must not run for snapshot failure");
|
||||||
|
result = await snapshotFailureClient.request("Svc", "requestSnapshotFailure");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /snapshot request state/i);
|
||||||
|
result = await snapshotFailureClient.stream("Svc", "streamSnapshotFailure", undefined, () => {});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /snapshot request state/i);
|
||||||
|
|
||||||
|
const contextClient = new Client("http://example.test");
|
||||||
|
contextClient.setState({ base: "global" });
|
||||||
|
const outcomeContexts = new Map();
|
||||||
|
contextClient.addResponseInterceptor((_service, method, _value, context) => {
|
||||||
|
outcomeContexts.set(method, context);
|
||||||
|
});
|
||||||
|
contextClient.addRequestInterceptor((_service, method, state) => {
|
||||||
|
state.method = method;
|
||||||
|
if (method === "requestHookFailure") throw new Error("request context hook");
|
||||||
|
});
|
||||||
|
globalThis.fetch = async () => assert.fail("fetch must not run before serialization");
|
||||||
|
const contextCyclic = {};
|
||||||
|
contextCyclic.self = contextCyclic;
|
||||||
|
result = await contextClient.request("Svc", "serializeContext", contextCyclic, { state: { base: "request" } });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.deepEqual(outcomeContexts.get("serializeContext").requestState, {
|
||||||
|
base: "request",
|
||||||
|
method: "serializeContext",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("serializeContext").response, undefined);
|
||||||
|
result = await contextClient.request("Svc", "requestHookFailure", undefined, { state: { request: "hook" } });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.deepEqual(outcomeContexts.get("requestHookFailure").requestState, {
|
||||||
|
base: "global",
|
||||||
|
request: "hook",
|
||||||
|
method: "requestHookFailure",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("requestHookFailure").response, undefined);
|
||||||
|
globalThis.fetch = async () => { throw new TypeError("offline"); };
|
||||||
|
result = await contextClient.request("Svc", "networkContext", undefined, { state: { request: "network" } });
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.deepEqual(outcomeContexts.get("networkContext").requestState, {
|
||||||
|
base: "global",
|
||||||
|
request: "network",
|
||||||
|
method: "networkContext",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("networkContext").response, undefined);
|
||||||
|
const bodyReadResponse = new Response(new ReadableStream({
|
||||||
|
pull(controller) { controller.error(new Error("context body read failed")); },
|
||||||
|
}));
|
||||||
|
globalThis.fetch = async () => bodyReadResponse;
|
||||||
|
result = await contextClient.request("Svc", "bodyReadContext", undefined, { state: { request: "body" } });
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.deepEqual(outcomeContexts.get("bodyReadContext").requestState, {
|
||||||
|
base: "global",
|
||||||
|
request: "body",
|
||||||
|
method: "bodyReadContext",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("bodyReadContext").response, bodyReadResponse);
|
||||||
|
|
||||||
|
const failingRequestInterceptor = new Client("http://example.test");
|
||||||
|
let normalized = [];
|
||||||
|
failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); });
|
||||||
|
failingRequestInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
|
||||||
|
result = await failingRequestInterceptor.request("Svc", "hook");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /request hook/);
|
||||||
|
assert.deepEqual(normalized, [1]);
|
||||||
|
|
||||||
|
const failingResponseInterceptor = new Client("http://example.test");
|
||||||
|
normalized = [];
|
||||||
|
failingResponseInterceptor.addResponseInterceptor(() => { throw new Error("response hook"); });
|
||||||
|
failingResponseInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
|
||||||
|
globalThis.fetch = async () => json({ status: 2, code: 4003, msg: "original" });
|
||||||
|
result = await failingResponseInterceptor.request("Svc", "responseHook");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /response hook/);
|
||||||
|
assert.deepEqual(normalized, [1]);
|
||||||
|
|
||||||
|
const cyclic = {};
|
||||||
|
cyclic.self = cyclic;
|
||||||
|
globalThis.fetch = async () => assert.fail("fetch must not run for serialization failure");
|
||||||
|
result = await client.request("Svc", "serialize", cyclic);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /serialize/i);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => json({ status: 2, code: 4002, msg: "stream business" }, 422);
|
||||||
|
result = await client.stream("Svc", "streamBusiness", undefined, () => {});
|
||||||
|
assert.deepEqual(result, { status: 2, code: 4002, msg: "stream business" });
|
||||||
|
expectSeen(2);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => json({ status: 0 });
|
||||||
|
result = await client.stream("Svc", "wrongMedia", undefined, () => {});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /x-ndjson/);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
const bytes = new TextEncoder().encode('{"text":"你好"}\r\n\n{"n":2}');
|
||||||
|
const split = bytes.indexOf(0xe5) + 1;
|
||||||
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(bytes.slice(0, split));
|
||||||
|
controller.enqueue(bytes.slice(split));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
}), { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } });
|
||||||
|
const messages = [];
|
||||||
|
result = await client.stream("Svc", "valid", undefined, value => messages.push(value));
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.deepEqual(messages, [{ text: "你好" }, { n: 2 }]);
|
||||||
|
expectSeen(0);
|
||||||
|
|
||||||
|
let malformedCancelled = false;
|
||||||
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
start(controller) { controller.enqueue(new TextEncoder().encode("bad\n")); },
|
||||||
|
cancel() { malformedCancelled = true; },
|
||||||
|
}), { headers: { "Content-Type": "application/x-ndjson" } });
|
||||||
|
cleanupCheck = () => malformedCancelled;
|
||||||
|
result = await client.stream("Svc", "malformed", undefined, () => {});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /line 1/);
|
||||||
|
assert.equal(malformedCancelled, true);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => ndjson('{"n":1}\n');
|
||||||
|
result = await client.stream("Svc", "callback", undefined, () => { throw new Error("callback boom"); });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /callback boom/);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
pull(controller) { controller.error(new Error("read boom")); },
|
||||||
|
}), { headers: { "Content-Type": "application/x-ndjson" } });
|
||||||
|
result = await client.stream("Svc", "read", undefined, () => {});
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.match(result.msg, /read boom/);
|
||||||
|
expectSeen(4);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => ndjson(new Uint8Array([0xff, 0x0a]));
|
||||||
|
result = await client.stream("Svc", "utf8", undefined, () => {});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /UTF-8/);
|
||||||
|
expectSeen(1);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => ndjson("");
|
||||||
|
result = await client.stream("Svc", "emptyStream", undefined, () => assert.fail("empty stream callback"));
|
||||||
|
assert.deepEqual(result, { status: 0 });
|
||||||
|
expectSeen(0);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response(null, {
|
||||||
|
headers: { "Content-Type": "application/x-ndjson" },
|
||||||
|
});
|
||||||
|
result = await client.stream("Svc", "nullBody", undefined, () => assert.fail("null stream callback"));
|
||||||
|
assert.deepEqual(result, { status: 0 });
|
||||||
|
expectSeen(0);
|
||||||
|
|
||||||
|
const streamAbort = new AbortController();
|
||||||
|
streamAbort.abort();
|
||||||
|
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
|
||||||
|
result = await client.stream("Svc", "abortStream", undefined, () => {}, { signal: streamAbort.signal });
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
expectSeen(4);
|
||||||
|
|
||||||
|
const readAbort = new AbortController();
|
||||||
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
readAbort.signal.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true });
|
||||||
|
readAbort.abort();
|
||||||
|
},
|
||||||
|
}), { headers: { "Content-Type": "application/x-ndjson" } });
|
||||||
|
result = await client.stream("Svc", "readAbort", undefined, () => {}, { signal: readAbort.signal });
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.match(result.msg, /aborted/i);
|
||||||
|
expectSeen(4);
|
||||||
|
|
||||||
|
const readTimeout = new AbortController();
|
||||||
|
readTimeout.abort(new DOMException("timed out", "TimeoutError"));
|
||||||
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
pull(controller) { controller.error(new DOMException("aborted", "AbortError")); },
|
||||||
|
}), { headers: { "Content-Type": "application/x-ndjson" } });
|
||||||
|
result = await client.stream("Svc", "timeoutStream", undefined, () => {}, { signal: readTimeout.signal });
|
||||||
|
assert.equal(result.status, 5);
|
||||||
|
expectSeen(5);
|
||||||
|
|
||||||
|
const streamHookClient = new Client("http://example.test");
|
||||||
|
normalized = [];
|
||||||
|
streamHookClient.addRequestInterceptor(() => { throw new Error("stream hook"); });
|
||||||
|
streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
|
||||||
|
result = await streamHookClient.stream("Svc", "hook", undefined, () => {});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.deepEqual(normalized, [1]);
|
||||||
|
|
||||||
|
const streamStateClient = new Client("http://example.test");
|
||||||
|
streamStateClient.setState({ shared: "global", globalOnly: "stream" });
|
||||||
|
const streamContexts = new Map();
|
||||||
|
const streamResponses = new Map();
|
||||||
|
const streamPayloads = new Map();
|
||||||
|
streamStateClient.addRequestInterceptor((_service, method, state) => {
|
||||||
|
state.interceptor = method;
|
||||||
|
if (method === "streamHookFailure") throw new Error("stream context hook");
|
||||||
|
});
|
||||||
|
streamStateClient.addResponseInterceptor((_service, method, _value, context) => {
|
||||||
|
streamContexts.set(method, context);
|
||||||
|
});
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
const payload = JSON.parse(init.body);
|
||||||
|
streamPayloads.set(payload.methodName, payload);
|
||||||
|
if (payload.methodName === "streamNetwork") throw new TypeError("stream offline");
|
||||||
|
const response = payload.methodName === "streamWrongMedia"
|
||||||
|
? json({ status: 0 })
|
||||||
|
: payload.methodName === "streamReadFailure"
|
||||||
|
? new Response(new ReadableStream({
|
||||||
|
pull(controller) { controller.error(new Error("stream context read failed")); },
|
||||||
|
}), { headers: { "Content-Type": "application/x-ndjson" } })
|
||||||
|
: ndjson('{"n":1}\n');
|
||||||
|
streamResponses.set(payload.methodName, response);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
result = await streamStateClient.stream("Svc", "streamSuccess", undefined, () => {}, {
|
||||||
|
state: { shared: "request", requestOnly: "success" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamWrongMedia", undefined, () => {}, {
|
||||||
|
state: { shared: "wrong-media" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamNetwork", undefined, () => {}, {
|
||||||
|
state: { shared: "network" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamReadFailure", undefined, () => {}, {
|
||||||
|
state: { shared: "read" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
const streamCyclic = {};
|
||||||
|
streamCyclic.self = streamCyclic;
|
||||||
|
result = await streamStateClient.stream("Svc", "streamSerialize", streamCyclic, () => {}, {
|
||||||
|
state: { shared: "serialize" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamHookFailure", undefined, () => {}, {
|
||||||
|
state: { shared: "hook" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
for (const method of ["streamSuccess", "streamWrongMedia", "streamNetwork", "streamReadFailure"]) {
|
||||||
|
const context = streamContexts.get(method);
|
||||||
|
assert.deepEqual(context.requestState, streamPayloads.get(method).state);
|
||||||
|
assert.equal(Object.isFrozen(context.requestState), true);
|
||||||
|
assert.equal(Object.isFrozen(context), true);
|
||||||
|
}
|
||||||
|
assert.equal(streamContexts.get("streamSuccess").response, streamResponses.get("streamSuccess"));
|
||||||
|
assert.equal(streamContexts.get("streamWrongMedia").response, streamResponses.get("streamWrongMedia"));
|
||||||
|
assert.equal(streamContexts.get("streamReadFailure").response, streamResponses.get("streamReadFailure"));
|
||||||
|
assert.equal(streamContexts.get("streamNetwork").response, undefined);
|
||||||
|
assert.deepEqual(streamContexts.get("streamSerialize").requestState, {
|
||||||
|
shared: "serialize",
|
||||||
|
globalOnly: "stream",
|
||||||
|
interceptor: "streamSerialize",
|
||||||
|
});
|
||||||
|
assert.equal(streamContexts.get("streamSerialize").response, undefined);
|
||||||
|
assert.deepEqual(streamContexts.get("streamHookFailure").requestState, {
|
||||||
|
shared: "hook",
|
||||||
|
globalOnly: "stream",
|
||||||
|
interceptor: "streamHookFailure",
|
||||||
|
});
|
||||||
|
assert.equal(streamContexts.get("streamHookFailure").response, undefined);
|
||||||
|
`
|
||||||
|
scriptPath := filepath.Join(dir, "behavior.mjs")
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := exec.Command(node, "--experimental-strip-types", scriptPath)
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("TypeScript client behavior failed: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeScriptClientStrictTypecheck(t *testing.T) {
|
||||||
|
tsc, err := exec.LookPath("tsc")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("tsc is not installed")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "client.ts")
|
||||||
|
if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
usagePath := filepath.Join(dir, "usage.ts")
|
||||||
|
const usage = `import { Client, type ContextResponseInterceptor, type RequestOptions, type ResponseContext, type ResponseInterceptor, type StreamOptions } from "./client";
|
||||||
|
|
||||||
|
const requestOptions: RequestOptions = { state: { token: "request" } };
|
||||||
|
const streamOptions: StreamOptions = { state: { token: "stream" } };
|
||||||
|
const legacy: ResponseInterceptor = (_service, _method, result) => result;
|
||||||
|
const legacyConsumer: (service: string, method: string, result: import("./client").result<any>) => unknown = legacy;
|
||||||
|
const current: ContextResponseInterceptor = (_service, _method, result, context) => {
|
||||||
|
const token: string | undefined = context.requestState.token;
|
||||||
|
const response: Response | undefined = context.response;
|
||||||
|
void token;
|
||||||
|
void response;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
void legacyConsumer;
|
||||||
|
const context = {} as ResponseContext;
|
||||||
|
// @ts-expect-error requestState is readonly
|
||||||
|
context.requestState.token = "changed";
|
||||||
|
const client = new Client("/");
|
||||||
|
client.addResponseInterceptor(legacy);
|
||||||
|
client.addResponseInterceptor(current);
|
||||||
|
void client.request("Svc", "method", undefined, requestOptions);
|
||||||
|
void client.stream("Svc", "method", undefined, () => {}, streamOptions);
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(usagePath, []byte(usage), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := exec.Command(tsc,
|
||||||
|
"--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path, usagePath)
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("strict TypeScript check failed: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeScriptClientSimplicityGate(t *testing.T) {
|
||||||
|
source := templateTs{}.genClientTemplate() + templateTs{}.genServiceTemplate()
|
||||||
|
for _, forbidden := range []string{"RpcError", "httpStatus", "result.aborted", "ClientErrorCode", "setTimeout"} {
|
||||||
|
if strings.Contains(source, forbidden) {
|
||||||
|
t.Errorf("generated TypeScript templates contain forbidden %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(source, "Promise<result<void>>") {
|
||||||
|
t.Fatal("stream methods must resolve result<void>")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// fun docs:拉起 API 文档站——侧栏服务/方法树、DTO 参数表单、
|
||||||
|
// 一键发送打到反向代理的后端,流式方法实时展示 NDJSON 行
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed docsui/index.html
|
||||||
|
var docsFS embed.FS
|
||||||
|
|
||||||
|
func cmdDocs(args []string) {
|
||||||
|
fs := flag.NewFlagSet("docs", flag.ExitOnError)
|
||||||
|
addr := fs.String("addr", "127.0.0.1:7788", "文档站监听地址")
|
||||||
|
upstream := fs.String("upstream", "http://127.0.0.1:8080", "后端服务地址(反向代理目标)")
|
||||||
|
from := fs.String("from", "", "从元数据 JSON 文件读取")
|
||||||
|
pkg := fs.String("p", ".", "业务 main 包路径(用于采集元数据与自动启动后端)")
|
||||||
|
noStart := fs.Bool("no-run", false, "不自动启动后端(后端已在别处运行时使用)")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
var meta *FunMeta
|
||||||
|
var err error
|
||||||
|
if *from != "" {
|
||||||
|
meta, err = loadMeta(*from)
|
||||||
|
} else {
|
||||||
|
var root string
|
||||||
|
root, err = findModuleRoot(*pkg)
|
||||||
|
if err == nil {
|
||||||
|
genDir, _ := os.MkdirTemp("", "fun-docs-gen-*")
|
||||||
|
defer os.RemoveAll(genDir)
|
||||||
|
err = runFunGen(root, "ts", genDir)
|
||||||
|
if err == nil {
|
||||||
|
meta, err = parseTsDir(filepath.Join(genDir, "ts"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: 采集元数据失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(meta.Services) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: 元数据中没有服务,请先 BindService")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动起后端:FUN_DUMP 采集进程已退出,这里再起一个常驻的
|
||||||
|
var backend *exec.Cmd
|
||||||
|
if !*noStart {
|
||||||
|
backend = goCommand("run", *pkg)
|
||||||
|
backend.Stdout, backend.Stderr = os.Stdout, os.Stderr
|
||||||
|
// 独立进程组:go run 的孙进程(真正的服务)随 CLI 一起被杀干净
|
||||||
|
backend.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if err := backend.Start(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: 启动后端失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := url.Parse(*upstream)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: upstream 地址无效:", err)
|
||||||
|
killChild(backend, nil)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
|
||||||
|
metaJSON := renderMetaJSON(meta)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/", "/index.html":
|
||||||
|
data, _ := docsFS.ReadFile("docsui/index.html")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
case "/fun-meta.json":
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Write(metaJSON)
|
||||||
|
default:
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigs
|
||||||
|
killChild(backend, nil)
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
nMethods := 0
|
||||||
|
for _, svc := range meta.Services {
|
||||||
|
nMethods += len(svc.Methods)
|
||||||
|
}
|
||||||
|
fmt.Printf(`
|
||||||
|
fun docs 已启动
|
||||||
|
─────────────────────────────────────────
|
||||||
|
文档站: http://%s
|
||||||
|
后端代理: %s → %s
|
||||||
|
─────────────────────────────────────────
|
||||||
|
%d 个服务 / %d 个方法。Ctrl+C 退出(连同自动拉起的后端)
|
||||||
|
`, *addr, *upstream, *upstream, len(meta.Services), nMethods)
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: 文档站启动失败:", err)
|
||||||
|
killChild(backend, nil)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMetaJSON(meta *FunMeta) []byte {
|
||||||
|
data, err := json.Marshal(meta)
|
||||||
|
if err != nil {
|
||||||
|
return []byte(`{"services":[]}`)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>fun docs</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#ffffff;--bg2:#f6f8fa;--line:#d8dee4;
|
||||||
|
--fg:#1f2328;--fg2:#636c76;--fg3:#8b949e;
|
||||||
|
--acc:#0969da;--accbg:#ddf4ff;
|
||||||
|
--ok:#1a7f37;--okbg:#dafbe1;--err:#cf222e;--errbg:#ffebe9;
|
||||||
|
--stream:#9a6700;--streambg:#fff8c5;
|
||||||
|
--code:#24292f;
|
||||||
|
--mono:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{background:var(--bg);color:var(--fg);font:14px/1.65 -apple-system,'Segoe UI',system-ui,'PingFang SC','Microsoft YaHei',sans-serif;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
/* 顶栏 */
|
||||||
|
header{display:flex;align-items:center;gap:10px;padding:9px 20px;background:var(--bg2);border-bottom:1px solid var(--line);flex-shrink:0}
|
||||||
|
header .logo{font-family:var(--mono);font-weight:700;font-size:15px;color:var(--fg)}
|
||||||
|
header .logo b{color:var(--acc)}
|
||||||
|
header .meta{color:var(--fg2);font-size:12.5px}
|
||||||
|
header .dot{width:7px;height:7px;border-radius:50%;background:var(--ok)}
|
||||||
|
#layout{flex:1;display:flex;min-height:0}
|
||||||
|
/* 侧栏 */
|
||||||
|
aside{width:280px;min-width:280px;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--bg)}
|
||||||
|
#search{margin:12px 14px 6px;padding:6px 10px;border:1px solid var(--line);border-radius:6px;font-size:13px;outline:none;background:var(--bg)}
|
||||||
|
#search:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--accbg)}
|
||||||
|
#tree{flex:1;overflow-y:auto;padding:2px 8px 16px}
|
||||||
|
.svc{margin:14px 8px 4px;font-size:11.5px;color:var(--fg2);font-weight:600;letter-spacing:.06em;text-transform:uppercase}
|
||||||
|
.mth{display:flex;align-items:center;gap:7px;padding:4px 9px;border-radius:6px;cursor:pointer;font-size:13.5px;color:var(--fg)}
|
||||||
|
.mth:hover{background:var(--bg2)}
|
||||||
|
.mth.on{background:var(--accbg);color:var(--fg)}
|
||||||
|
.mth.on::before{content:'';display:inline-block;width:3px;height:14px;background:var(--acc);border-radius:2px;margin-right:2px}
|
||||||
|
.mth .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.mth.on .nm{font-weight:600}
|
||||||
|
.badge{font-size:10px;padding:1px 6px;border-radius:10px;font-family:var(--mono);border:1px solid}
|
||||||
|
.b-stream{color:var(--stream);border-color:var(--streambg);background:var(--streambg)}
|
||||||
|
.b-void{color:var(--fg2);background:var(--bg2);border-color:var(--line)}
|
||||||
|
/* 正文 */
|
||||||
|
main{flex:1;overflow-y:auto;padding:26px 34px 60px}
|
||||||
|
.doc{max-width:860px}
|
||||||
|
h1{font-size:24px;font-weight:600;letter-spacing:-.01em;font-family:var(--mono);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||||
|
.tag{font-size:11px;font-weight:500;padding:2px 9px;border-radius:10px;font-family:var(--mono)}
|
||||||
|
.t-stream{color:var(--stream);background:var(--streambg)}
|
||||||
|
.t-void{color:var(--fg2);background:var(--bg2);border:1px solid var(--line)}
|
||||||
|
.sig{margin:8px 0 4px;color:var(--fg2);font-size:13px;font-family:var(--mono);background:var(--bg2);border:1px solid var(--line);border-radius:6px;padding:8px 12px}
|
||||||
|
.sig b{color:var(--acc);font-weight:600}
|
||||||
|
h2{font-size:17px;font-weight:600;margin:26px 0 10px;padding-top:18px;border-top:1px solid var(--line)}
|
||||||
|
h2 .sub{font-weight:400;color:var(--fg2);font-size:12.5px;margin-left:8px}
|
||||||
|
/* 参数表 */
|
||||||
|
table.params{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
table.params th{text-align:left;color:var(--fg2);font-weight:600;font-size:12px;padding:6px 10px;border-bottom:2px solid var(--line);white-space:nowrap}
|
||||||
|
table.params td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
|
table.params tr:last-child td{border-bottom:none}
|
||||||
|
table.params td.name{font-family:var(--mono);white-space:nowrap}
|
||||||
|
table.params td.type{font-family:var(--mono);color:var(--fg2);white-space:nowrap}
|
||||||
|
table.params td.name .req{color:var(--err);font-weight:700}
|
||||||
|
table.params td.name .opt{color:var(--fg3);font-size:11px}
|
||||||
|
input[type=text],textarea,select{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:5px 9px;font-size:13px;font-family:var(--mono);outline:none}
|
||||||
|
input:focus,textarea:focus,select:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--accbg)}
|
||||||
|
textarea{min-height:60px;resize:vertical}
|
||||||
|
input[type=checkbox]{accent-color:var(--acc);width:14px;height:14px}
|
||||||
|
.nest{border-left:2px solid var(--line);margin:6px 0 2px;padding-left:14px}
|
||||||
|
.subhdr{font-family:var(--mono);font-size:12px;color:var(--fg2);margin:10px 0 4px}
|
||||||
|
/* state */
|
||||||
|
/* 发送 */
|
||||||
|
#sendBar{display:flex;gap:10px;align-items:center;margin:4px 0 4px}
|
||||||
|
#send{background:var(--acc);color:#fff;border:none;border-radius:6px;padding:8px 24px;font-size:14px;font-weight:600;cursor:pointer}
|
||||||
|
#send:hover{background:#0860c4}
|
||||||
|
#send:disabled{opacity:.55;cursor:wait}
|
||||||
|
#send.stop{background:var(--err)}
|
||||||
|
#copyReq{background:var(--bg);border:1px solid var(--line);color:var(--fg2);border-radius:6px;padding:7px 14px;cursor:pointer;font-size:13px}
|
||||||
|
#copyReq:hover{border-color:var(--acc);color:var(--acc)}
|
||||||
|
#sendHint{color:var(--fg2);font-size:12.5px}
|
||||||
|
/* 请求/响应示例块 */
|
||||||
|
.block{border:1px solid var(--line);border-radius:8px;margin-top:8px;overflow:hidden}
|
||||||
|
.block .bhead{display:flex;align-items:center;gap:10px;padding:7px 14px;background:var(--bg2);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:12px;color:var(--fg2)}
|
||||||
|
.chip{padding:1px 9px;border-radius:10px;font-weight:500}
|
||||||
|
.c-ok{color:var(--ok);background:var(--okbg)}
|
||||||
|
.c-err{color:var(--err);background:var(--errbg)}
|
||||||
|
.c-info{color:var(--acc);background:var(--accbg)}
|
||||||
|
#stateRows .srow{display:grid;grid-template-columns:1fr 1.4fr 30px;gap:8px;margin-bottom:6px;max-width:560px}
|
||||||
|
#stateRows .srow button{background:none;border:none;color:var(--fg3);cursor:pointer;font-size:15px}
|
||||||
|
#stateRows .srow button:hover{color:var(--err)}
|
||||||
|
#addState{background:var(--bg);border:1px dashed var(--line);color:var(--fg2);border-radius:6px;padding:3px 12px;cursor:pointer;font-size:12.5px}
|
||||||
|
#addState:hover{border-color:var(--acc);color:var(--acc)}
|
||||||
|
textarea.body{width:100%;border:none;outline:none;background:var(--bg);padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.6;overflow:auto;max-height:50vh;white-space:pre;resize:vertical;color:var(--code);display:block}
|
||||||
|
pre.body{padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.6;overflow:auto;max-height:50vh;white-space:pre-wrap;word-break:break-all;color:var(--code)}
|
||||||
|
.empty{color:var(--fg3);font-size:12.5px;font-family:var(--mono)}
|
||||||
|
.note{color:var(--fg2);font-size:12.5px;margin-top:6px}
|
||||||
|
.note code,td.hint code{font-family:var(--mono);background:var(--bg2);border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:11.5px}
|
||||||
|
::-webkit-scrollbar{width:10px;height:10px}
|
||||||
|
::-webkit-scrollbar-thumb{background:#c9d1d9;border-radius:5px;border:2px solid var(--bg)}
|
||||||
|
::-webkit-scrollbar-thumb:hover{background:#afb8c1}
|
||||||
|
#empty{color:var(--fg3);margin-top:20vh;text-align:center;font-size:14px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<span class="logo">fun<b>·docs</b></span>
|
||||||
|
<span class="dot" id="live"></span>
|
||||||
|
<span class="meta" id="hmeta">加载中…</span>
|
||||||
|
</header>
|
||||||
|
<div id="layout">
|
||||||
|
<aside>
|
||||||
|
<input id="search" placeholder="搜索服务或方法…" autofocus>
|
||||||
|
<div id="tree"></div>
|
||||||
|
</aside>
|
||||||
|
<main id="main"><div id="empty">在左侧选择一个方法查看文档</div></main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let META=null, CUR=null, ABORT=null;
|
||||||
|
const $=s=>document.querySelector(s);
|
||||||
|
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
|
||||||
|
fetch('/fun-meta.json').then(r=>r.json()).then(m=>{
|
||||||
|
META=m;
|
||||||
|
const nS=m.services.length;let nM=0;m.services.forEach(s=>nM+=s.methods.length);
|
||||||
|
$('#hmeta').textContent=`${nS} 个服务 · ${nM} 个方法`;
|
||||||
|
renderTree('');
|
||||||
|
}).catch(e=>{$('#hmeta').textContent='元数据加载失败: '+e.message;$('#live').style.background='var(--err)'});
|
||||||
|
|
||||||
|
$('#search').addEventListener('input',e=>renderTree(e.target.value));
|
||||||
|
|
||||||
|
function renderTree(q){
|
||||||
|
q=(q||'').toLowerCase();
|
||||||
|
const t=$('#tree');t.innerHTML='';
|
||||||
|
META.services.forEach(svc=>{
|
||||||
|
const ms=svc.methods.filter(m=>!q||m.name.toLowerCase().includes(q)||svc.name.toLowerCase().includes(q));
|
||||||
|
if(!ms.length)return;
|
||||||
|
const h=document.createElement('div');h.className='svc';h.textContent=svc.name;t.appendChild(h);
|
||||||
|
ms.forEach(m=>{
|
||||||
|
const d=document.createElement('div');d.className='mth';
|
||||||
|
d.innerHTML=`<span class="nm">${esc(m.name)}</span>`+
|
||||||
|
(m.isStream?'<span class="badge b-stream">stream</span>':'')+
|
||||||
|
(m.void?'<span class="badge b-void">void</span>':'');
|
||||||
|
d.onclick=()=>{document.querySelectorAll('.mth').forEach(x=>x.classList.remove('on'));d.classList.add('on');showMethod(svc,m)};
|
||||||
|
t.appendChild(d);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(t){
|
||||||
|
if(!t)return '—';
|
||||||
|
if(t.kind==='struct'||t.kind==='enum')return t.name+(t.optional?'?':'');
|
||||||
|
if(t.kind==='slice')return typeLabel(t.elem)+'[]';
|
||||||
|
return (t.optional?t.name+'?':t.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMethod(svc,m){
|
||||||
|
CUR={svc,m};reqDirty=false;ABORT&&ABORT.abort();ABORT=null;
|
||||||
|
const badges=(m.isStream?'<span class="tag t-stream">NDJSON 流式</span>':'')+(m.void?'<span class="tag t-void">无返回值</span>':'');
|
||||||
|
const retLine = m.isStream
|
||||||
|
? (m.returns?`首条消息 <b>${esc(typeLabel(m.returns))}</b>,其后逐行推送 NDJSON`:`持续推送 NDJSON 行`)
|
||||||
|
: (m.void?`无数据返回,仅 <b>result.void</b>`:(m.returns?`<b>${esc(typeLabel(m.returns))}</b>`:''));
|
||||||
|
const dtoName = m.dto?esc(m.dto.name):'无';
|
||||||
|
$('#main').innerHTML=`<div class="doc">
|
||||||
|
<h1>${esc(svc.name)}.${esc(m.name)} ${badges}</h1>
|
||||||
|
<div class="sig"><b>POST</b> /cell · serviceName=<b>${esc(svc.name)}</b> · methodName=<b>${esc(m.name)}</b></div>
|
||||||
|
<div class="note">返回:${retLine} · 请求 DTO:${dtoName}${m.dto&&m.dto.fields?'':'(该方法不接收参数)'}</div>
|
||||||
|
|
||||||
|
${m.dto?`
|
||||||
|
<h2>请求参数<span class="sub">${esc(m.dto.name)}</span></h2>
|
||||||
|
${paramsTable(m.dto,[])}
|
||||||
|
<div class="note">带 <code>*</code> 为必填;指针/切片字段可留空省略;切片按 JSON 数组填写。字段名以小写开头随请求传输。</div>
|
||||||
|
`:''}
|
||||||
|
|
||||||
|
<h2>State<span class="sub">随请求携带 · Guard 可读 · 存于 localStorage</span></h2>
|
||||||
|
<div id="stateRows"></div>
|
||||||
|
<button id="addState">+ 添加字段</button>
|
||||||
|
|
||||||
|
<h2>试一下<span class="sub">填好参数点发送,请求与响应都会展示在下方</span></h2>
|
||||||
|
<div id="sendBar">
|
||||||
|
<button id="send">发送请求</button>
|
||||||
|
<button id="copyReq">复制请求 JSON</button>
|
||||||
|
<span id="sendHint"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>请求<span class="sub">表单填充自动生成,可直接编辑后发送</span></h2>
|
||||||
|
<div class="note" style="margin:0 0 8px">请求体即发送到 <code>POST /cell</code> 的 JSON,State 值由上方字段注入,也可手动编辑后发送。</div>
|
||||||
|
<div class="block"><div class="bhead" id="reqHead"><span class="chip c-info">表单生成</span><button id="resetReq" style="margin-left:auto;background:none;border:1px solid var(--line);border-radius:5px;color:var(--fg2);font-size:11px;padding:1px 8px;cursor:pointer;display:none">按表单重置</button></div><textarea class="body edit" id="reqBody" spellcheck="false"></textarea></div>
|
||||||
|
|
||||||
|
<h2>响应</h2>
|
||||||
|
<div class="block"><div class="bhead" id="respHead"><span class="chip c-info">未发送</span></div><pre class="body" id="respBody"><span class="empty">发送后在此展示格式化的响应${m.isStream?'(流式方法逐行追加)':''}</span></pre></div>
|
||||||
|
</div>`;
|
||||||
|
$('#addState').onclick=()=>addStateRow('','');
|
||||||
|
const st=JSON.parse(localStorage.getItem('fun-docs-state')||'{}');
|
||||||
|
Object.entries(st).forEach(([k,v])=>addStateRow(k,v));
|
||||||
|
$('#send').onclick=send;
|
||||||
|
$('#copyReq').onclick=copyReq;
|
||||||
|
// 表单一变就同步请求体(手动编辑过则不动,可点"按表单重置"恢复)
|
||||||
|
document.querySelectorAll('#main input,#main select').forEach(el=>{
|
||||||
|
el.addEventListener('input',previewRequest);el.addEventListener('change',previewRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('#main textarea[data-t]').forEach(el=>{
|
||||||
|
el.addEventListener('input',previewRequest);el.addEventListener('change',previewRequest);
|
||||||
|
});
|
||||||
|
$('#reqBody').addEventListener('input',()=>{
|
||||||
|
reqDirty=true;
|
||||||
|
$('#reqHead').querySelector('.chip').textContent='手动编辑';
|
||||||
|
$('#resetReq').style.display='';
|
||||||
|
});
|
||||||
|
$('#resetReq').onclick=()=>{reqDirty=false;$('#resetReq').style.display='none';$('#reqHead').querySelector('.chip').textContent='表单生成';previewRequest();};
|
||||||
|
previewRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 参数表 ---------- */
|
||||||
|
function paramsTable(t,path){
|
||||||
|
let h=`<table class="params"><tr><th style="width:190px">字段</th><th style="width:130px">类型</th><th style="width:270px">示例值</th><th>说明</th></tr>`;
|
||||||
|
h+=paramRows(t,path);
|
||||||
|
return h+'</table>';
|
||||||
|
}
|
||||||
|
function paramRows(t,path){
|
||||||
|
let h='';
|
||||||
|
t.fields.forEach(f=>{
|
||||||
|
const p=[...path,f.name];
|
||||||
|
const id='f-'+p.join('.');
|
||||||
|
const req=!f.type.optional&&f.type.kind!=='slice';
|
||||||
|
h+=`<tr><td class="name">${esc(firstLower(f.name))}${f.type.optional?'<span class="opt"> 可空</span>':'<span class="req">*</span>'}</td><td class="type">${esc(typeLabel(f.type))}</td>`;
|
||||||
|
h+=`<td>${controlHTML(f.type,id)}</td>`;
|
||||||
|
h+=`<td class="hint" style="color:var(--fg2);font-size:12.5px">${hintFor(f.type)}</td></tr>`;
|
||||||
|
if(f.type.kind==='struct')h+=`<tr><td colspan="4"><div class="nest"><div class="subhdr">${esc(f.type.name)} 字段:</div>${paramsTable(f.type,p)}</div></td></tr>`;
|
||||||
|
});
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function hintFor(t){
|
||||||
|
switch(t.kind){
|
||||||
|
case 'int':return '定宽整数,<code>int64</code> 全链路精度';
|
||||||
|
case 'enum':return '枚举序号:'+(t.names||[]).map((n,i)=>(t.displayNames&&t.displayNames[i]?t.displayNames[i]:n)+'='+i).join(' · ');
|
||||||
|
case 'slice':return 'JSON 数组,如 <code>["a","b"]</code>';
|
||||||
|
case 'struct':return '嵌套对象';
|
||||||
|
case 'bool':return 'true / false';
|
||||||
|
default:return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function controlHTML(t,id){
|
||||||
|
if(t.optional){
|
||||||
|
return `<div style="display:flex;gap:7px;align-items:center"><input type="checkbox" class="prov" data-for="${id}" title="勾选后提供该字段"><div style="flex:1" data-prov="${id}" data-dis>${innerControl(t,id)}</div></div>`;
|
||||||
|
}
|
||||||
|
return innerControl(t,id);
|
||||||
|
}
|
||||||
|
function innerControl(t,id){
|
||||||
|
switch(t.kind){
|
||||||
|
case 'string':return `<input type="text" id="${id}" data-t="string">`;
|
||||||
|
case 'int':return `<input type="text" id="${id}" data-t="int" inputmode="numeric" placeholder="0">`;
|
||||||
|
case 'bool':return `<select id="${id}" data-t="bool"><option value=""></option><option>true</option><option>false</option></select>`;
|
||||||
|
case 'enum':{
|
||||||
|
let h=`<select id="${id}" data-t="enum"><option value=""></option>`;
|
||||||
|
(t.names||[]).forEach((n,i)=>h+=`<option value="${i}">${esc((t.displayNames&&t.displayNames[i])?t.displayNames[i]+' ('+n+')':n)}</option>`);
|
||||||
|
return h+'</select>';
|
||||||
|
}
|
||||||
|
case 'slice':return `<textarea id="${id}" data-t="slice" placeholder='["a","b"] 或 [{...}]'></textarea>`;
|
||||||
|
default:return `<input type="text" id="${id}" data-t="string">`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstLower=s=>s?s[0].toLowerCase()+s.slice(1):s;
|
||||||
|
|
||||||
|
// 指针字段勾选联动
|
||||||
|
document.addEventListener('change',e=>{
|
||||||
|
if(e.target.classList&&e.target.classList.contains('prov')){
|
||||||
|
const wrap=document.querySelector(`[data-prov="${e.target.dataset.for}"]`);
|
||||||
|
if(wrap){const el=wrap.querySelector('input,textarea,select');if(el)el.disabled=!e.target.checked}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 取值 ---------- */
|
||||||
|
function collectStruct(t,path,obj,errors){
|
||||||
|
t.fields.forEach(f=>{
|
||||||
|
const p=[...path,f.name];
|
||||||
|
const id='f-'+p.join('.');
|
||||||
|
const key=firstLower(f.name);
|
||||||
|
let el=document.getElementById(id);
|
||||||
|
let provided=true;
|
||||||
|
if(f.type.optional){
|
||||||
|
const cb=document.querySelector(`.prov[data-for="${id}"]`);
|
||||||
|
provided=cb&&cb.checked;
|
||||||
|
}
|
||||||
|
if(!provided)return;
|
||||||
|
if(!el)return;
|
||||||
|
const v=el.value!=null?el.value.trim():'';
|
||||||
|
const kind=f.type.kind;
|
||||||
|
if(kind==='struct'){obj[key]=collectStruct(f.type,p,{},errors);return}
|
||||||
|
if(v===''){
|
||||||
|
const req=!f.type.optional&&kind!=='slice'&&kind!=='bool'&&kind!=='enum';
|
||||||
|
if(req)errors.push(`缺少必填字段: ${p.join('.')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch(kind){
|
||||||
|
case 'int':{
|
||||||
|
const n=Number(v);
|
||||||
|
if(!Number.isFinite(n)||!Number.isInteger(n)){errors.push(`${p.join('.')} 不是整数`);return}
|
||||||
|
obj[key]=n;break;
|
||||||
|
}
|
||||||
|
case 'bool':obj[key]=v==='true';break;
|
||||||
|
case 'enum':obj[key]=Number(v);break;
|
||||||
|
case 'slice':{
|
||||||
|
try{obj[key]=JSON.parse(v)}catch{errors.push(`${p.join('.')} 不是合法 JSON 数组`)}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:obj[key]=v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
let reqDirty=false;
|
||||||
|
function buildRequest(){
|
||||||
|
const errors=[];
|
||||||
|
const body={serviceName:CUR.svc.name,methodName:CUR.m.name};
|
||||||
|
if(CUR.m.dto)body.data=collectStruct(CUR.m.dto,[],{},errors);
|
||||||
|
const state={};
|
||||||
|
document.querySelectorAll('#stateRows .srow').forEach(r=>{
|
||||||
|
const k=r.querySelector('[data-sk]').value.trim(),v=r.querySelector('[data-sv]').value;
|
||||||
|
if(k)state[k]=v;
|
||||||
|
});
|
||||||
|
if(Object.keys(state).length)body.state=state;
|
||||||
|
localStorage.setItem('fun-docs-state',JSON.stringify(state));
|
||||||
|
return {body,errors};
|
||||||
|
}
|
||||||
|
function addStateRow(k,v){
|
||||||
|
const d=document.createElement('div');d.className='srow';
|
||||||
|
d.innerHTML=`<input type="text" placeholder="key(如 token)" data-sk value="${esc(k)}"><input type="text" placeholder="value" data-sv value="${esc(v)}"><button title="删除">×</button>`;
|
||||||
|
d.querySelector('button').onclick=()=>d.remove();
|
||||||
|
$('#stateRows').appendChild(d);
|
||||||
|
d.querySelectorAll('input').forEach(i=>i.addEventListener('input',previewRequest));
|
||||||
|
}
|
||||||
|
function previewRequest(){
|
||||||
|
if(!CUR||reqDirty)return;
|
||||||
|
const {body,errors}=buildRequest();
|
||||||
|
const pre=$('#reqBody');if(!pre)return;
|
||||||
|
if(errors.length){pre.value=JSON.stringify({serviceName:CUR.svc.name,methodName:CUR.m.name},null,2);return}
|
||||||
|
pre.value=pretty(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
function requestBody(){
|
||||||
|
const raw=$('#reqBody').value.trim();
|
||||||
|
try{return{ok:JSON.parse(raw)}}catch(e){return{ok:null,err:e.message}}
|
||||||
|
}
|
||||||
|
function copyReq(){
|
||||||
|
const raw=$('#reqBody').value.trim();
|
||||||
|
try{JSON.parse(raw);navigator.clipboard.writeText(JSON.stringify(JSON.parse(raw),null,2))}
|
||||||
|
catch{navigator.clipboard.writeText(raw)}
|
||||||
|
$('#sendHint').textContent='已复制';
|
||||||
|
setTimeout(()=>$('#sendHint').textContent='',1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 发送 ---------- */
|
||||||
|
async function send(){
|
||||||
|
if(ABORT){ABORT.abort();return}
|
||||||
|
const parsed=requestBody();
|
||||||
|
if(!parsed.ok){
|
||||||
|
$('#reqHead').innerHTML='<span class="chip c-err">请求 JSON 无效</span>';
|
||||||
|
$('#respBody').innerHTML=`<span style="color:var(--err)">${esc(parsed.err)}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body=parsed.ok;
|
||||||
|
try{$('#reqBody').value=pretty(JSON.stringify(body))}catch{}
|
||||||
|
$('#reqHead').innerHTML='<span class="chip c-info">POST /cell</span>';
|
||||||
|
const btn=$('#send');btn.disabled=true;
|
||||||
|
const t0=performance.now();
|
||||||
|
$('#respHead').innerHTML='<span class="chip c-info">发送中…</span>';
|
||||||
|
$('#respBody').innerHTML='<span class="empty">等待响应…</span>';
|
||||||
|
ABORT=new AbortController();
|
||||||
|
try{
|
||||||
|
const res=await fetch('/cell',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:ABORT.signal});
|
||||||
|
const ms=Math.round(performance.now()-t0);
|
||||||
|
if(CUR.m.isStream){
|
||||||
|
btn.textContent='停止';btn.classList.add('stop');btn.disabled=false;
|
||||||
|
await readStream(res,ms);
|
||||||
|
}else{
|
||||||
|
const text=await res.text();
|
||||||
|
$('#respHead').innerHTML=statusChip(res.status,ms,res.headers.get('content-type'));
|
||||||
|
$('#respBody').textContent=text?pretty(text):'(空响应体)';
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
if(e.name==='AbortError')$('#respHead').innerHTML='<span class="chip c-err">已取消</span>';
|
||||||
|
else $('#respHead').innerHTML='<span class="chip c-err">请求失败</span>';
|
||||||
|
$('#respBody').textContent=String(e.message||e);
|
||||||
|
}finally{
|
||||||
|
ABORT=null;btn.textContent='发送请求';btn.classList.remove('stop');btn.disabled=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function readStream(res,ms){
|
||||||
|
$('#respHead').innerHTML=statusChip(res.status,ms,'application/x-ndjson');
|
||||||
|
const pre=$('#respBody');pre.textContent='';
|
||||||
|
if(!res.ok||!(res.headers.get('content-type')||'').includes('ndjson')){
|
||||||
|
pre.textContent=pretty(await res.text());return;
|
||||||
|
}
|
||||||
|
const reader=res.body.getReader(),dec=new TextDecoder();
|
||||||
|
let buf='',lines=0;
|
||||||
|
while(true){
|
||||||
|
if(!ABORT)break;
|
||||||
|
const {done,value}=await reader.read();
|
||||||
|
if(done)break;
|
||||||
|
buf+=dec.decode(value,{stream:true});
|
||||||
|
let i;
|
||||||
|
while((i=buf.indexOf('\n'))>=0){
|
||||||
|
const line=buf.slice(0,i).trim();buf=buf.slice(i+1);
|
||||||
|
if(!line)continue;
|
||||||
|
lines++;pre.textContent+=pretty(line)+'\n';
|
||||||
|
if(lines>800)pre.textContent='(超出 800 行,已截断)\n'+pre.textContent.split('\n').slice(-800).join('\n');
|
||||||
|
pre.scrollTop=pre.scrollHeight;
|
||||||
|
$('#respHead').lastChild&&$('#respHead').lastChild.remove();
|
||||||
|
$('#respHead').insertAdjacentHTML('beforeend',`<span class="chip c-ok">流式 · ${lines} 行</span>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#respHead').insertAdjacentHTML('beforeend','<span class="chip c-info">流结束</span>');
|
||||||
|
}
|
||||||
|
function statusChip(status,ms,ct){
|
||||||
|
const ok=status>=200&&status<300;
|
||||||
|
return `<span class="chip ${ok?'c-ok':'c-err'}">HTTP ${status}</span><span class="chip c-info">${ms} ms</span>`+(ct?`<span class="chip c-info">${esc(ct.split(';')[0])}</span>`:'');
|
||||||
|
}
|
||||||
|
function pretty(text){
|
||||||
|
try{return JSON.stringify(JSON.parse(text),null,2)}catch{return text}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// fun gen:从运行中的服务采集元数据,渲染 Go/TS 客户端代码
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goCommand 组装 go run/build 命令:-p 为目录时切换工作目录,
|
||||||
|
// 否则视为当前模块内的包路径(如 ./cmd/server)
|
||||||
|
func goCommand(subcmd string, pkg string, extra ...string) *exec.Cmd {
|
||||||
|
args := append([]string{subcmd}, extra...)
|
||||||
|
if st, err := os.Stat(pkg); err == nil && st.IsDir() {
|
||||||
|
args = append(args, ".")
|
||||||
|
cmd := exec.Command("go", args...)
|
||||||
|
cmd.Dir = pkg
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
args = append(args, pkg)
|
||||||
|
return exec.Command("go", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectMeta 采集元数据:优先 -from 文件,否则 FUN_DUMP=<tmp> go run <pkg>
|
||||||
|
func collectMeta(pkg string, from string) (*FunMeta, error) {
|
||||||
|
if from != "" {
|
||||||
|
return loadMeta(from)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp("", "fun-meta-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
tmp.Close()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
cmd := goCommand("run", pkg)
|
||||||
|
cmd.Env = append(os.Environ(), "FUN_DUMP="+tmpPath)
|
||||||
|
cmd.Stdout = os.Stderr // 用户程序的输出不污染元数据文件
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("启动 go run 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
// 用户 main 需要响应 FUN_DUMP(调用 f.Start 的都会自动响应);
|
||||||
|
// 首次 go run 含编译耗时,给足余量
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("go run 退出: %w(程序需调用 f.Start 或显式处理 fun.DumpRequested)", err)
|
||||||
|
}
|
||||||
|
case <-time.After(120 * time.Second):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
return nil, fmt.Errorf("等待元数据超时:程序 120 秒内未退出。请确认 main 调用了 f.Start(会自动响应 FUN_DUMP 导出后退出),或用 fun.DumpRequested + f.DumpMetadata 显式导出")
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadMeta(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdGen(args []string) {
|
||||||
|
fs := flag.NewFlagSet("gen", flag.ExitOnError)
|
||||||
|
out := fs.String("o", "./gen", "输出目录(会被清空重建)")
|
||||||
|
from := fs.String("from", "", "从元数据 JSON 文件读取(跳过 go run 采集)")
|
||||||
|
pkg := fs.String("p", ".", "业务 main 包路径")
|
||||||
|
fs.Parse(reorderFlags(args))
|
||||||
|
|
||||||
|
rest := fs.Args()
|
||||||
|
wantTS, wantGo := false, false
|
||||||
|
switch {
|
||||||
|
case len(rest) == 0:
|
||||||
|
wantTS, wantGo = true, true
|
||||||
|
case len(rest) == 1 && (rest[0] == "ts" || rest[0] == "go"):
|
||||||
|
wantTS = rest[0] == "ts"
|
||||||
|
wantGo = rest[0] == "go"
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(os.Stderr, "用法: fun gen [ts|go] [-o 输出目录] [-from meta.json] [-p 包路径]")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := "both"
|
||||||
|
if wantTS && !wantGo {
|
||||||
|
lang = "ts"
|
||||||
|
} else if wantGo && !wantTS {
|
||||||
|
lang = "go"
|
||||||
|
}
|
||||||
|
|
||||||
|
if *from != "" {
|
||||||
|
meta, err := loadMeta(*from)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: 读取元数据失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
files := fileSet{}
|
||||||
|
if wantTS {
|
||||||
|
renderTs{}.renderAll(meta, files)
|
||||||
|
}
|
||||||
|
if wantGo {
|
||||||
|
renderGo{}.renderAll(meta, files)
|
||||||
|
}
|
||||||
|
if err := writeFiles(*out, files); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: 写入产物失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
reportGen(*out, files)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := findModuleRoot(*pkg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := runFunGen(root, lang, *out); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
count := countGenFiles(*out)
|
||||||
|
fmt.Printf("fun: 已生成客户端(%s)→ %s\n", lang, *out)
|
||||||
|
for _, name := range count {
|
||||||
|
fmt.Println(" 生成:", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportGen(out string, files fileSet) {
|
||||||
|
names := make([]string, 0, len(files))
|
||||||
|
for name := range files {
|
||||||
|
names = append(names, filepath.ToSlash(name))
|
||||||
|
}
|
||||||
|
sortStrings(names)
|
||||||
|
fmt.Printf("fun: 已生成 %d 个文件 → %s\n", len(files), out)
|
||||||
|
for _, name := range names {
|
||||||
|
fmt.Println(" 生成:", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countGenFiles(out string) []string {
|
||||||
|
var names []string
|
||||||
|
filepath.WalkDir(out, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
rel, _ := filepath.Rel(out, path)
|
||||||
|
names = append(names, filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sortStrings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortStrings(s []string) {
|
||||||
|
for i := 1; i < len(s); i++ {
|
||||||
|
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||||
|
s[j], s[j-1] = s[j-1], s[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reorderFlags 把 -x 值 对挪到位置参数之前,让 "fun gen ts -o dir" 与
|
||||||
|
// "fun gen -o dir ts" 等价(flag 包遇到首个位置参数就停止解析)
|
||||||
|
func reorderFlags(args []string) []string {
|
||||||
|
var flags, rest []string
|
||||||
|
i := 0
|
||||||
|
for i < len(args) {
|
||||||
|
a := args[i]
|
||||||
|
if strings.HasPrefix(a, "-") && a != "-" && a != "--" {
|
||||||
|
flags = append(flags, a)
|
||||||
|
// 有值的标志:-o dir / -odir
|
||||||
|
if !strings.Contains(a, "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||||
|
flags = append(flags, args[i+1])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, a)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return append(flags, rest...)
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 渲染回归测试:断言与 fun v1.3.7 内置生成器产物一致的关键特征
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderToDir(t *testing.T, fixture string) string {
|
||||||
|
t.Helper()
|
||||||
|
meta, err := loadMeta(filepath.Join("testdata", fixture))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := fileSet{}
|
||||||
|
renderTs{}.renderAll(meta, files)
|
||||||
|
renderGo{}.renderAll(meta, files)
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := writeFiles(dir, files); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func read(t *testing.T, root, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile(filepath.Join(root, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file %s missing: %v", name, err)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTypeScriptSignaturesAndImports(t *testing.T) {
|
||||||
|
root := renderToDir(t, "alpha.json")
|
||||||
|
|
||||||
|
client := read(t, root, filepath.Join("ts", "client.ts"))
|
||||||
|
for _, want := range []string{
|
||||||
|
`state?: Record<string, string>;`,
|
||||||
|
`export type ResponseContext = {`,
|
||||||
|
`readonly requestState: Readonly<Record<string, string>>;`,
|
||||||
|
`readonly response?: Response;`,
|
||||||
|
`state = { ...this.state, ...options?.state };`,
|
||||||
|
`interceptor(serviceName, methodName, current, context)`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(client, want) {
|
||||||
|
t.Errorf("client.ts missing %q:\n%s", want, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alpha := read(t, root, filepath.Join("ts", "alphaGenSvc.ts"))
|
||||||
|
if first := strings.SplitN(alpha, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions } from "./client";` {
|
||||||
|
t.Fatalf("unexpected request-only imports: %s", first)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`async ping(options?: RequestOptions): Promise<result<void>>`,
|
||||||
|
`this.client.request<void>("alphaGenSvc", "ping", undefined, options)`,
|
||||||
|
`async alpha(dto:alphaGenDto, options?: RequestOptions): Promise<result<zebraGenDto>>`,
|
||||||
|
`this.client.request<zebraGenDto>("alphaGenSvc", "alpha", dto, options)`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(alpha, want) {
|
||||||
|
t.Errorf("alphaGenSvc.ts missing %q:\n%s", want, alpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Index(alpha, `import type alphaGenDto`) > strings.Index(alpha, `import type zebraGenDto`) {
|
||||||
|
t.Fatalf("DTO imports are not sorted:\n%s", alpha)
|
||||||
|
}
|
||||||
|
if strings.Index(alpha, `async alpha`) > strings.Index(alpha, `async ping`) ||
|
||||||
|
strings.Index(alpha, `async ping`) > strings.Index(alpha, `async zebra`) {
|
||||||
|
t.Fatalf("methods are not sorted:\n%s", alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream := read(t, root, filepath.Join("ts", "zebraGenSvc.ts"))
|
||||||
|
if first := strings.SplitN(stream, "\n", 2)[0]; first != `import { Client, type result, type StreamOptions } from "./client";` {
|
||||||
|
t.Fatalf("unexpected stream-only imports: %s", first)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`async watch(onMessage: (data: any) => unknown, options?: StreamOptions): Promise<result<void>>`,
|
||||||
|
`this.client.stream<any>("zebraGenSvc", "watch", undefined, onMessage, options)`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(stream, want) {
|
||||||
|
t.Errorf("zebraGenSvc.ts missing %q:\n%s", want, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixed := read(t, root, filepath.Join("ts", "mixedGenSvc.ts"))
|
||||||
|
if first := strings.SplitN(mixed, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions, type StreamOptions } from "./client";` {
|
||||||
|
t.Fatalf("unexpected mixed imports: %s", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderGoErrorOnlyMethod(t *testing.T) {
|
||||||
|
root := renderToDir(t, "alpha.json")
|
||||||
|
goSrc := read(t, root, filepath.Join("go", "alpha_gen_svc.go"))
|
||||||
|
if !strings.Contains(goSrc, "Result[Void]") {
|
||||||
|
t.Fatalf("go: expect Result[Void], got:\n%s", goSrc)
|
||||||
|
}
|
||||||
|
tsSrc := read(t, root, filepath.Join("ts", "alphaGenSvc.ts"))
|
||||||
|
if !strings.Contains(tsSrc, "result<void>") {
|
||||||
|
t.Fatalf("ts: expect result<void>, got:\n%s", tsSrc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDeterministic(t *testing.T) {
|
||||||
|
first := renderToDir(t, "alpha.json")
|
||||||
|
second := renderToDir(t, "alpha.json")
|
||||||
|
|
||||||
|
files1, files2 := walkFiles(t, first), walkFiles(t, second)
|
||||||
|
if len(files1) != len(files2) {
|
||||||
|
t.Fatalf("file count changed: %d != %d", len(files1), len(files2))
|
||||||
|
}
|
||||||
|
for name, body := range files1 {
|
||||||
|
if files2[name] != body {
|
||||||
|
t.Errorf("generated file changed between runs: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tsFun := files1[filepath.Join("ts", "fun.ts")]
|
||||||
|
positions := []int{
|
||||||
|
strings.Index(tsFun, `import alphaGenSvc`),
|
||||||
|
strings.Index(tsFun, `import mixedGenSvc`),
|
||||||
|
strings.Index(tsFun, `import zebraGenSvc`),
|
||||||
|
}
|
||||||
|
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
|
||||||
|
t.Fatalf("TypeScript services are not sorted:\n%s", tsFun)
|
||||||
|
}
|
||||||
|
goFun := files1[filepath.Join("go", "fun.go")]
|
||||||
|
positions = []int{
|
||||||
|
strings.Index(goFun, "AlphaGenSvc *AlphaGenSvc"),
|
||||||
|
strings.Index(goFun, "MixedGenSvc *MixedGenSvc"),
|
||||||
|
strings.Index(goFun, "ZebraGenSvc *ZebraGenSvc"),
|
||||||
|
}
|
||||||
|
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
|
||||||
|
t.Fatalf("Go services are not sorted:\n%s", goFun)
|
||||||
|
}
|
||||||
|
goService := files1[filepath.Join("go", "alpha_gen_svc.go")]
|
||||||
|
positions = []int{
|
||||||
|
strings.Index(goService, "func (ctx *AlphaGenSvc) Alpha("),
|
||||||
|
strings.Index(goService, "func (ctx *AlphaGenSvc) Ping("),
|
||||||
|
strings.Index(goService, "func (ctx *AlphaGenSvc) Zebra("),
|
||||||
|
}
|
||||||
|
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
|
||||||
|
t.Fatalf("Go methods are not sorted:\n%s", goService)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderDemoFixture 用真实导出的示例元数据验证枚举、可空字段、流式首条消息
|
||||||
|
func TestRenderDemoFixture(t *testing.T) {
|
||||||
|
root := renderToDir(t, "demo.json")
|
||||||
|
|
||||||
|
enumGo := read(t, root, filepath.Join("go", "order_status.go"))
|
||||||
|
for _, want := range []string{"type OrderStatus uint8", "OrderStatus = iota", `func (OrderStatus) DisplayNames()`} {
|
||||||
|
if !strings.Contains(enumGo, want) {
|
||||||
|
t.Errorf("order_status.go missing %q:\n%s", want, enumGo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enumTs := read(t, root, filepath.Join("ts", "orderStatus.ts"))
|
||||||
|
if !strings.Contains(enumTs, "enum orderStatus {") {
|
||||||
|
t.Errorf("orderStatus.ts missing enum:\n%s", enumTs)
|
||||||
|
}
|
||||||
|
|
||||||
|
dtoTs := read(t, root, filepath.Join("ts", "createOrderDto.ts"))
|
||||||
|
for _, want := range []string{"sku:string", "count:number", "note?:string | null", "tags:string[]"} {
|
||||||
|
if !strings.Contains(dtoTs, want) {
|
||||||
|
t.Errorf("createOrderDto.ts missing %q:\n%s", want, dtoTs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatTs := read(t, root, filepath.Join("ts", "chatSvc.ts"))
|
||||||
|
if !strings.Contains(chatTs, `this.client.stream<string>("chatSvc", "ask"`) {
|
||||||
|
t.Errorf("chatSvc.ts missing first-message stream signature:\n%s", chatTs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkFiles(t *testing.T, root string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
out := map[string]string{}
|
||||||
|
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(root, path)
|
||||||
|
out[rel] = string(body)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 采集辅助:在 BindService 调用所在的包目录临时生成 fun_gen_helper_test.go
|
||||||
|
// (测试文件可访问包内类型,天然解决"服务定义在 package main"不可 import 的问题),
|
||||||
|
// go test 执行完毕后删除。fun 框架本体零改动,只用其公开 API
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helperTestFile = "fun_gen_helper_test.go"
|
||||||
|
const helperTestName = "TestFunGenHelper"
|
||||||
|
|
||||||
|
// helperTarget 一个待生成辅助测试的包目录
|
||||||
|
type helperTarget struct {
|
||||||
|
Dir string
|
||||||
|
Pkg string
|
||||||
|
Refs []serviceRef
|
||||||
|
}
|
||||||
|
|
||||||
|
type helperRef struct {
|
||||||
|
Alias string // 引用别名(&pkg.Svc 的 pkg);同包引用为空
|
||||||
|
Import string // 引用的导入路径
|
||||||
|
TypeName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFunGen 扫描绑定调用,生成辅助测试并执行:
|
||||||
|
// lang: "ts" / "go" / "both";out 为 GenCode 输出目录(会被 fun 清空重建)
|
||||||
|
func runFunGen(moduleRoot, lang, out string) error {
|
||||||
|
targets, err := scanBindTargets(moduleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return fmt.Errorf("未在模块中找到 BindService/BindServiceForGen 调用。" +
|
||||||
|
"请确认服务注册代码,或用 -from 直接指定元数据 JSON 文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理保险:先移除可能的历史残留
|
||||||
|
defer cleanupHelpers(targets)
|
||||||
|
cleanupHelpers(targets)
|
||||||
|
|
||||||
|
pkgs := make([]string, 0, len(targets))
|
||||||
|
for _, t := range targets {
|
||||||
|
if err := writeHelperTest(moduleRoot, t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(moduleRoot, t.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pkg := "./" + filepath.ToSlash(rel)
|
||||||
|
pkgs = append(pkgs, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append([]string{"test", "-vet=off", "-count=1", "-run", "^" + helperTestName + "$"}, pkgs...)
|
||||||
|
cmd := exec.Command("go", args...)
|
||||||
|
cmd.Dir = moduleRoot
|
||||||
|
cmd.Env = append(os.Environ(), "FUN_OUT="+out, "FUN_LANG="+lang)
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
cleanupHelpers(targets)
|
||||||
|
return fmt.Errorf("采集执行失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupHelpers(targets []helperTarget) {
|
||||||
|
for _, t := range targets {
|
||||||
|
os.Remove(filepath.Join(t.Dir, helperTestFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHelperTest(moduleRoot string, t helperTarget) error {
|
||||||
|
// 汇总需要的外部导入(去重,按路径排序)
|
||||||
|
imports := map[string]string{} // alias → path
|
||||||
|
for _, r := range t.Refs {
|
||||||
|
if r.Alias != "" {
|
||||||
|
imports[r.Alias] = r.Import
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := make([]string, 0, len(imports))
|
||||||
|
for _, p := range imports {
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
aliasOf := func(path string) string {
|
||||||
|
for a, p := range imports {
|
||||||
|
if p == path {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var imp, binds strings.Builder
|
||||||
|
imp.WriteString("\t\"os\"\n\t\"testing\"\n\n\t\"github.com/cyi-cc/fun\"\n")
|
||||||
|
for _, p := range paths {
|
||||||
|
fmt.Fprintf(&imp, "\t%s %q\n", aliasOf(p), p)
|
||||||
|
}
|
||||||
|
for _, r := range t.Refs {
|
||||||
|
if r.Alias != "" {
|
||||||
|
fmt.Fprintf(&binds, "\tf.BindServiceForGen(&%s.%s{})\n", r.Alias, r.TypeName)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&binds, "\tf.BindServiceForGen(&%s{})\n", r.TypeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
src := fmt.Sprintf(`// fun-cli 临时采集辅助(自动生成,执行完即删除)
|
||||||
|
package %s
|
||||||
|
|
||||||
|
import (
|
||||||
|
%s)
|
||||||
|
|
||||||
|
func %s(t *testing.T) {
|
||||||
|
f := fun.New()
|
||||||
|
%s fun.SetOutput(os.Getenv("FUN_OUT"))
|
||||||
|
switch os.Getenv("FUN_LANG") {
|
||||||
|
case "ts":
|
||||||
|
fun.GenCode(fun.GenTs{})
|
||||||
|
case "go":
|
||||||
|
fun.GenCode(fun.GenGo{})
|
||||||
|
default:
|
||||||
|
fun.GenCode(fun.GenGo{}, fun.GenTs{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, t.Pkg, imp.String(), helperTestName, binds.String())
|
||||||
|
return os.WriteFile(filepath.Join(t.Dir, helperTestFile), []byte(src), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// fun-cli:fun 框架的配套命令行工具
|
||||||
|
//
|
||||||
|
// fun gen [ts|go] 生成 TypeScript / Go 客户端
|
||||||
|
// fun run 热更新开发循环
|
||||||
|
// fun docs 拉起 API 文档站(参数表单 + 在线发送测试)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "1.0.0"
|
||||||
|
|
||||||
|
const usage = `fun-cli ` + version + ` — fun 框架配套工具
|
||||||
|
|
||||||
|
用法:
|
||||||
|
fun gen [ts|go] [-o 目录] [-p 模块路径] [-from meta.json]
|
||||||
|
生成客户端代码(fun 核心直接产出,与内置 GenCode 完全一致)。
|
||||||
|
扫描模块内 BindService/BindServiceForGen 调用,在对应包目录临时
|
||||||
|
生成辅助测试执行采集,跑完即删;或 -from 直接读元数据 JSON。
|
||||||
|
|
||||||
|
fun run [-p 包路径] [-- 程序参数]
|
||||||
|
热更新:改 .go/go.mod/go.sum 自动重编译重启。先编译成功再切换,
|
||||||
|
编译失败保留旧进程。Ctrl+C 退出。
|
||||||
|
|
||||||
|
fun docs [-addr 地址] [-upstream 后端URL] [-p 包路径] [-no-run] [-from meta.json]
|
||||||
|
API 文档站:服务/方法树、DTO 参数表单、State 编辑、一键发送;
|
||||||
|
流式方法实时展示 NDJSON 行。默认自动 go run 拉起后端并反向代理 /cell。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
fun gen ts -o frontend/src/api
|
||||||
|
fun run -p ./cmd/server
|
||||||
|
fun docs -upstream http://127.0.0.1:9000 -p .
|
||||||
|
|
||||||
|
安装:
|
||||||
|
go install github.com/cyi-cc/fun-cli@latest
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Print(usage)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "gen":
|
||||||
|
cmdGen(os.Args[2:])
|
||||||
|
case "run":
|
||||||
|
cmdRun(os.Args[2:])
|
||||||
|
case "docs":
|
||||||
|
cmdDocs(os.Args[2:])
|
||||||
|
case "-v", "version":
|
||||||
|
fmt.Println("fun-cli", version)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "未知命令: %s\n\n", os.Args[1])
|
||||||
|
fmt.Print(usage)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 元数据 JSON 契约:与 fun 核心库 meta.go 的导出结构一一对应。
|
||||||
|
// CLI 不依赖 fun 模块,仅消费该 JSON,保持零第三方依赖
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FunMeta struct {
|
||||||
|
Services []MetaService `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetaService struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Methods []MetaMethod `json:"methods"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetaMethod struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsStream bool `json:"isStream"`
|
||||||
|
Void bool `json:"void,omitempty"`
|
||||||
|
DTO *MetaType `json:"dto,omitempty"`
|
||||||
|
Returns *MetaType `json:"returns,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetaType struct {
|
||||||
|
Kind string `json:"kind"` // int/bool/string/struct/slice/enum
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Optional bool `json:"optional,omitempty"`
|
||||||
|
Fields []MetaField `json:"fields,omitempty"`
|
||||||
|
Elem *MetaType `json:"elem,omitempty"`
|
||||||
|
|
||||||
|
Names []string `json:"names,omitempty"`
|
||||||
|
DisplayNames []string `json:"displayNames,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetaField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type *MetaType `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMeta 从文件读取元数据
|
||||||
|
func loadMeta(path string) (*FunMeta, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var meta FunMeta
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &meta, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 渲染层共享类型与工具:模板数据结构与 fun v1.3.7 gen.go 保持一致,
|
||||||
|
// 保证产物字节级不变
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type genType struct {
|
||||||
|
GenServiceList []*genServiceType
|
||||||
|
}
|
||||||
|
|
||||||
|
type genMethodType struct {
|
||||||
|
MethodName string
|
||||||
|
ReturnValueText string
|
||||||
|
DtoText string
|
||||||
|
ArgsText string
|
||||||
|
GenericTypeText string
|
||||||
|
IsProxy bool
|
||||||
|
IsStream bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type genEnumType struct {
|
||||||
|
Names []string
|
||||||
|
DisplayNames []string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type genImportType struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type genServiceType struct {
|
||||||
|
ServiceName string
|
||||||
|
GenMethodTypeList []*genMethodType
|
||||||
|
GenImport []*genImportType
|
||||||
|
IsIncludeProxy bool
|
||||||
|
IsIncludeRequest bool
|
||||||
|
IsIncludeStream bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type genClassType struct {
|
||||||
|
Name string
|
||||||
|
GenImport []*genImportType
|
||||||
|
GenClassFieldType []*genClassFieldType
|
||||||
|
}
|
||||||
|
|
||||||
|
type genClassFieldType struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicateServiceImports(imports []*genImportType) []*genImportType {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var result []*genImportType
|
||||||
|
for _, imp := range imports {
|
||||||
|
if !seen[imp.Name] {
|
||||||
|
seen[imp.Name] = true
|
||||||
|
result = append(result, imp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGenericTypeParams(typeName string) string {
|
||||||
|
start := strings.Index(typeName, "[")
|
||||||
|
end := strings.LastIndex(typeName, "]")
|
||||||
|
paramsStr := typeName[start+1 : end]
|
||||||
|
params := strings.Split(paramsStr, ",")
|
||||||
|
for i, param := range params {
|
||||||
|
LL := strings.Split(strings.TrimSpace(param), ".")
|
||||||
|
params[i] = firstLetterToUpper(LL[len(LL)-1])
|
||||||
|
}
|
||||||
|
return strings.Join(params, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGenericTypeName(typeName string) string {
|
||||||
|
start := strings.Index(typeName, "[")
|
||||||
|
return typeName[0:start]
|
||||||
|
}
|
||||||
|
|
||||||
|
func camelToSnake(s string) string {
|
||||||
|
re := regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||||
|
snake := re.ReplaceAllString(s, `${1}_${2}`)
|
||||||
|
return strings.ToLower(snake)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLetterToUpper(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s[:1]) + s[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLetterToLower(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.ToLower(s[:1]) + s[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSet 渲染产物集合:相对路径 → 内容
|
||||||
|
type fileSet map[string][]byte
|
||||||
|
|
||||||
|
// renderCode 模板渲染(等价旧 genCode 的模板执行部分,写盘由调用方统一处理)
|
||||||
|
func renderCode(templateContent string, outputFileName string, templateData any, languageName string, files fileSet) {
|
||||||
|
tmpl, err := template.New(languageName).Parse(templateContent)
|
||||||
|
if err != nil {
|
||||||
|
panic(err.Error())
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = tmpl.Execute(&buf, templateData)
|
||||||
|
if err != nil {
|
||||||
|
panic(err.Error())
|
||||||
|
}
|
||||||
|
files[filepath.Join(languageName, outputFileName+"."+languageName)] = buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFiles 清空输出目录后写入全部产物
|
||||||
|
func writeFiles(dir string, files fileSet) error {
|
||||||
|
if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(files))
|
||||||
|
for name := range files {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
full := filepath.Join(dir, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, files[name], 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Go 渲染器:fun v1.3.7 gen_go.go 的等价移植,输入从 reflect.Type 换成元数据类型树
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type renderGo struct{}
|
||||||
|
|
||||||
|
// typeName 等价旧 typeToTemplateType:指针 → "*" 前缀,切片 → "[]",其余用原始类型名
|
||||||
|
func (renderGo) typeName(t *MetaType) string {
|
||||||
|
prefix := ""
|
||||||
|
if t.Optional {
|
||||||
|
prefix = "*"
|
||||||
|
}
|
||||||
|
if t.Kind == "slice" {
|
||||||
|
return prefix + "[]" + renderGo{}.typeName(t.Elem)
|
||||||
|
}
|
||||||
|
return prefix + t.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderGo) genService(svc MetaService, serviceContext *genServiceType, files fileSet) {
|
||||||
|
for _, gm := range svc.Methods {
|
||||||
|
var returnValueText string
|
||||||
|
var dtoText string
|
||||||
|
var argsText string
|
||||||
|
var genericTypeText string
|
||||||
|
|
||||||
|
// () error:无数据返回,客户端类型为 Void
|
||||||
|
if gm.Void {
|
||||||
|
genericTypeText = "Void"
|
||||||
|
returnValueText = "Result[Void]"
|
||||||
|
if gm.DTO != nil {
|
||||||
|
v := r.typeName(gm.DTO)
|
||||||
|
if !strings.Contains(v, "[]") && strings.Contains(v, "[") {
|
||||||
|
dtoText += "dto " + getGenericTypeName(v) + parseGenericTypeParams(v)
|
||||||
|
} else {
|
||||||
|
dtoText += "dto " + v
|
||||||
|
}
|
||||||
|
argsText += ",dto"
|
||||||
|
r.genStruct(gm.DTO, files)
|
||||||
|
}
|
||||||
|
serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{
|
||||||
|
MethodName: gm.Name,
|
||||||
|
ReturnValueText: returnValueText,
|
||||||
|
DtoText: dtoText,
|
||||||
|
ArgsText: argsText,
|
||||||
|
GenericTypeText: genericTypeText,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if gm.IsStream {
|
||||||
|
serviceContext.IsIncludeStream = true
|
||||||
|
if gm.Returns == nil {
|
||||||
|
genericTypeText = "any"
|
||||||
|
returnValueText = "Void"
|
||||||
|
} else {
|
||||||
|
t := r.typeName(gm.Returns)
|
||||||
|
if !strings.Contains(t, "[]") && strings.Contains(t, "[") {
|
||||||
|
genericTypeText = getGenericTypeName(t) + parseGenericTypeParams(t)
|
||||||
|
} else {
|
||||||
|
genericTypeText = t
|
||||||
|
}
|
||||||
|
returnValueText = genericTypeText
|
||||||
|
r.genReturnTypes(gm.Returns, files)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t := r.typeName(gm.Returns)
|
||||||
|
if !strings.Contains(t, "[]") && strings.Contains(t, "[") {
|
||||||
|
returnValueText = getGenericTypeName(t) + parseGenericTypeParams(t)
|
||||||
|
} else {
|
||||||
|
returnValueText = t
|
||||||
|
}
|
||||||
|
genericTypeText = returnValueText
|
||||||
|
r.genReturnTypes(gm.Returns, files)
|
||||||
|
returnValueText = "Result[" + returnValueText + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if gm.DTO != nil {
|
||||||
|
v := r.typeName(gm.DTO)
|
||||||
|
if !strings.Contains(v, "[]") && strings.Contains(v, "[") {
|
||||||
|
dtoText += "dto " + getGenericTypeName(v) + parseGenericTypeParams(v)
|
||||||
|
} else {
|
||||||
|
dtoText += "dto " + v
|
||||||
|
}
|
||||||
|
argsText += ",dto"
|
||||||
|
r.genStruct(gm.DTO, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{
|
||||||
|
MethodName: gm.Name,
|
||||||
|
ReturnValueText: returnValueText,
|
||||||
|
DtoText: dtoText,
|
||||||
|
ArgsText: argsText,
|
||||||
|
GenericTypeText: genericTypeText,
|
||||||
|
IsStream: gm.IsStream,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderCode(templateGo{}.genServiceTemplate(), camelToSnake(svc.Name), serviceContext, "go", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// genReturnTypes 递归生成返回类型涉及的 struct/enum 定义
|
||||||
|
func (r renderGo) genReturnTypes(returnType *MetaType, files fileSet) {
|
||||||
|
if returnType.Kind == "struct" {
|
||||||
|
r.genStruct(returnType, files)
|
||||||
|
}
|
||||||
|
if returnType.Kind == "slice" && returnType.Elem.Kind == "struct" {
|
||||||
|
r.genStruct(returnType.Elem, files)
|
||||||
|
}
|
||||||
|
if returnType.Kind == "enum" {
|
||||||
|
r.getEnum(returnType, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderGo) renderAll(meta *FunMeta, files fileSet) {
|
||||||
|
genContext := genType{GenServiceList: []*genServiceType{}}
|
||||||
|
|
||||||
|
for _, svc := range meta.Services {
|
||||||
|
serviceContext := &genServiceType{
|
||||||
|
ServiceName: svc.Name,
|
||||||
|
GenMethodTypeList: []*genMethodType{},
|
||||||
|
}
|
||||||
|
genContext.GenServiceList = append(genContext.GenServiceList, serviceContext)
|
||||||
|
r.genService(svc, serviceContext, files)
|
||||||
|
}
|
||||||
|
renderCode(templateGo{}.genDefaultServiceTemplate(), "fun", genContext, "go", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderGo) genStruct(t *MetaType, files fileSet) *genImportType {
|
||||||
|
var structTemplate genClassType
|
||||||
|
if strings.Contains(t.Name, "[]") == false && strings.Contains(t.Name, "[") {
|
||||||
|
structTemplate = genClassType{
|
||||||
|
Name: getGenericTypeName(t.Name) + parseGenericTypeParams(t.Name),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
structTemplate = genClassType{
|
||||||
|
Name: t.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range t.Fields {
|
||||||
|
fieldType := field.Type
|
||||||
|
jsType := r.typeName(fieldType)
|
||||||
|
tag := "`json:\"" + firstLetterToLower(field.Name) + "\"`"
|
||||||
|
if !strings.Contains(jsType, "[]") && strings.Contains(jsType, "[") {
|
||||||
|
structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{
|
||||||
|
Name: field.Name,
|
||||||
|
Type: getGenericTypeName(jsType) + parseGenericTypeParams(jsType),
|
||||||
|
Tag: tag,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{
|
||||||
|
Name: field.Name,
|
||||||
|
Type: jsType,
|
||||||
|
Tag: tag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldType.Kind == "struct" {
|
||||||
|
r.genStruct(fieldType, files)
|
||||||
|
}
|
||||||
|
if fieldType.Kind == "slice" && fieldType.Elem.Kind == "struct" {
|
||||||
|
r.genStruct(fieldType.Elem, files)
|
||||||
|
}
|
||||||
|
if fieldType.Kind == "enum" {
|
||||||
|
r.getEnum(fieldType, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCode(
|
||||||
|
templateGo{}.genStructTemplate(),
|
||||||
|
camelToSnake(structTemplate.Name),
|
||||||
|
structTemplate,
|
||||||
|
"go",
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
return &genImportType{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderGo) getEnum(t *MetaType, files fileSet) *genImportType {
|
||||||
|
var enumTemplate genEnumType
|
||||||
|
enumTemplate.Names = t.Names
|
||||||
|
enumTemplate.DisplayNames = t.DisplayNames
|
||||||
|
enumTemplate.Name = t.Name
|
||||||
|
|
||||||
|
renderCode(
|
||||||
|
templateGo{}.genEnumTemplate(),
|
||||||
|
camelToSnake(t.Name),
|
||||||
|
enumTemplate,
|
||||||
|
"go",
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
return &genImportType{}
|
||||||
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// TS 渲染器:fun v1.3.7 gen_ts.go 的等价移植,输入从 reflect.Type 换成元数据类型树
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type renderTs struct{}
|
||||||
|
|
||||||
|
// typeName 等价旧 typeToTemplateType:指针 → " | null" 后缀,切片 → "[]",
|
||||||
|
// 整数 → number(枚举除外),struct/string → 类型名
|
||||||
|
func (renderTs) typeName(t *MetaType) string {
|
||||||
|
suffix := ""
|
||||||
|
if t.Optional {
|
||||||
|
suffix = " | null"
|
||||||
|
}
|
||||||
|
switch t.Kind {
|
||||||
|
case "enum":
|
||||||
|
return t.Name + suffix
|
||||||
|
case "int":
|
||||||
|
return "number" + suffix
|
||||||
|
case "bool":
|
||||||
|
return "boolean" + suffix
|
||||||
|
case "string", "struct":
|
||||||
|
return t.Name + suffix
|
||||||
|
default: // slice
|
||||||
|
return renderTs{}.typeName(t.Elem) + "[]" + suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderTs) genService(svc MetaService, serviceContext *genServiceType, files fileSet) {
|
||||||
|
var nestedImports []*genImportType
|
||||||
|
|
||||||
|
for _, gm := range svc.Methods {
|
||||||
|
var returnValueText string
|
||||||
|
var dtoText string
|
||||||
|
var argsText string
|
||||||
|
var genericTypeText string
|
||||||
|
|
||||||
|
// () error:无数据返回,客户端类型为 void
|
||||||
|
if gm.Void {
|
||||||
|
genericTypeText = "void"
|
||||||
|
returnValueText = "result<void>"
|
||||||
|
if gm.DTO != nil {
|
||||||
|
v := firstLetterToLower(r.typeName(gm.DTO))
|
||||||
|
if !strings.Contains(v, "[]") && strings.Contains(v, "[") {
|
||||||
|
dtoText += "dto:" + getGenericTypeName(v) + parseGenericTypeParams(v)
|
||||||
|
} else {
|
||||||
|
dtoText += "dto:" + v
|
||||||
|
}
|
||||||
|
argsText += ",dto"
|
||||||
|
nestedImports = append(nestedImports, r.genStruct(gm.DTO, files))
|
||||||
|
}
|
||||||
|
serviceContext.IsIncludeRequest = true
|
||||||
|
serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{
|
||||||
|
MethodName: firstLetterToLower(gm.Name),
|
||||||
|
ReturnValueText: returnValueText,
|
||||||
|
DtoText: dtoText,
|
||||||
|
ArgsText: argsText,
|
||||||
|
GenericTypeText: genericTypeText,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if gm.IsStream {
|
||||||
|
serviceContext.IsIncludeStream = true
|
||||||
|
if gm.Returns == nil {
|
||||||
|
genericTypeText = "any"
|
||||||
|
returnValueText = "void"
|
||||||
|
} else {
|
||||||
|
t := firstLetterToLower(r.typeName(gm.Returns))
|
||||||
|
if !strings.Contains(t, "[]") && strings.Contains(t, "[") {
|
||||||
|
genericTypeText = getGenericTypeName(t) + parseGenericTypeParams(t)
|
||||||
|
} else {
|
||||||
|
genericTypeText = t
|
||||||
|
}
|
||||||
|
returnValueText = "void"
|
||||||
|
nestedImports = r.genReturnTypes(gm.Returns, nestedImports, files)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serviceContext.IsIncludeRequest = true
|
||||||
|
t := firstLetterToLower(r.typeName(gm.Returns))
|
||||||
|
if !strings.Contains(t, "[]") && strings.Contains(t, "[") {
|
||||||
|
returnValueText = getGenericTypeName(t) + parseGenericTypeParams(t)
|
||||||
|
} else {
|
||||||
|
returnValueText = t
|
||||||
|
}
|
||||||
|
genericTypeText = returnValueText
|
||||||
|
nestedImports = r.genReturnTypes(gm.Returns, nestedImports, files)
|
||||||
|
returnValueText = "result<" + returnValueText + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
if gm.DTO != nil {
|
||||||
|
v := firstLetterToLower(r.typeName(gm.DTO))
|
||||||
|
if !strings.Contains(v, "[]") && strings.Contains(v, "[") {
|
||||||
|
dtoText += "dto:" + getGenericTypeName(v) + parseGenericTypeParams(v)
|
||||||
|
} else {
|
||||||
|
dtoText += "dto:" + v
|
||||||
|
}
|
||||||
|
argsText += ",dto"
|
||||||
|
nestedImports = append(nestedImports, r.genStruct(gm.DTO, files))
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{
|
||||||
|
MethodName: firstLetterToLower(gm.Name),
|
||||||
|
ReturnValueText: returnValueText,
|
||||||
|
DtoText: dtoText,
|
||||||
|
ArgsText: argsText,
|
||||||
|
GenericTypeText: firstLetterToLower(genericTypeText),
|
||||||
|
IsStream: gm.IsStream,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
serviceContext.GenImport = deduplicateServiceImports(nestedImports)
|
||||||
|
|
||||||
|
renderCode(
|
||||||
|
templateTs{}.genServiceTemplate(),
|
||||||
|
firstLetterToLower(svc.Name),
|
||||||
|
serviceContext,
|
||||||
|
"ts",
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// genReturnTypes 递归生成返回类型涉及的 struct/enum 导入
|
||||||
|
func (r renderTs) genReturnTypes(returnType *MetaType, nestedImports []*genImportType, files fileSet) []*genImportType {
|
||||||
|
if returnType.Kind == "struct" {
|
||||||
|
nestedImports = append(nestedImports, r.genStruct(returnType, files))
|
||||||
|
}
|
||||||
|
if returnType.Kind == "slice" && returnType.Elem.Kind == "struct" {
|
||||||
|
nestedImports = append(nestedImports, r.genStruct(returnType.Elem, files))
|
||||||
|
}
|
||||||
|
if returnType.Kind == "enum" {
|
||||||
|
nestedImports = append(nestedImports, r.getEnum(returnType, files))
|
||||||
|
}
|
||||||
|
return nestedImports
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderTs) renderAll(meta *FunMeta, files fileSet) {
|
||||||
|
genContext := genType{GenServiceList: []*genServiceType{}}
|
||||||
|
|
||||||
|
for _, svc := range meta.Services {
|
||||||
|
serviceContext := &genServiceType{
|
||||||
|
ServiceName: firstLetterToLower(svc.Name),
|
||||||
|
GenMethodTypeList: []*genMethodType{},
|
||||||
|
}
|
||||||
|
genContext.GenServiceList = append(genContext.GenServiceList, serviceContext)
|
||||||
|
r.genService(svc, serviceContext, files)
|
||||||
|
}
|
||||||
|
renderCode(templateTs{}.genClientTemplate(), "client", nil, "ts", files)
|
||||||
|
renderCode(templateTs{}.genDefaultServiceTemplate(), "fun", genContext, "ts", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderTs) genStruct(t *MetaType, files fileSet) *genImportType {
|
||||||
|
var structTemplate genClassType
|
||||||
|
if strings.Contains(t.Name, "[]") == false && strings.Contains(t.Name, "[") {
|
||||||
|
structTemplate = genClassType{
|
||||||
|
Name: firstLetterToLower(getGenericTypeName(t.Name)) + parseGenericTypeParams(t.Name),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
structTemplate = genClassType{
|
||||||
|
Name: firstLetterToLower(t.Name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var nestedImports []*genImportType
|
||||||
|
|
||||||
|
for _, field := range t.Fields {
|
||||||
|
fieldType := field.Type
|
||||||
|
jsType := r.typeName(fieldType)
|
||||||
|
name := field.Name
|
||||||
|
if fieldType.Optional {
|
||||||
|
name += "?"
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsType, "[]") && strings.Contains(jsType, "[") {
|
||||||
|
structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{
|
||||||
|
Name: firstLetterToLower(name),
|
||||||
|
Type: firstLetterToLower(getGenericTypeName(jsType)) + parseGenericTypeParams(jsType),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
structTemplate.GenClassFieldType = append(structTemplate.GenClassFieldType, &genClassFieldType{
|
||||||
|
Name: firstLetterToLower(name),
|
||||||
|
Type: firstLetterToLower(jsType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldType.Kind == "struct" {
|
||||||
|
nestedImports = append(nestedImports, r.genStruct(fieldType, files))
|
||||||
|
}
|
||||||
|
if fieldType.Kind == "slice" && fieldType.Elem.Kind == "struct" {
|
||||||
|
nestedImports = append(nestedImports, r.genStruct(fieldType.Elem, files))
|
||||||
|
}
|
||||||
|
if fieldType.Kind == "enum" {
|
||||||
|
nestedImports = append(nestedImports, r.getEnum(fieldType, files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
structTemplate.GenImport = deduplicateServiceImports(nestedImports)
|
||||||
|
|
||||||
|
renderCode(
|
||||||
|
templateTs{}.genStructTemplate(),
|
||||||
|
structTemplate.Name,
|
||||||
|
structTemplate,
|
||||||
|
"ts",
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
|
||||||
|
if strings.Contains(t.Name, "[]") == false && strings.Contains(t.Name, "[") {
|
||||||
|
return &genImportType{Name: structTemplate.Name}
|
||||||
|
}
|
||||||
|
return &genImportType{Name: firstLetterToLower(t.Name)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r renderTs) getEnum(t *MetaType, files fileSet) *genImportType {
|
||||||
|
var enumTemplate genEnumType
|
||||||
|
enumTemplate.Names = t.Names
|
||||||
|
enumTemplate.DisplayNames = t.DisplayNames
|
||||||
|
enumTemplate.Name = firstLetterToLower(t.Name)
|
||||||
|
|
||||||
|
renderCode(
|
||||||
|
templateTs{}.genEnumTemplate(),
|
||||||
|
firstLetterToLower(t.Name),
|
||||||
|
enumTemplate,
|
||||||
|
"ts",
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
return &genImportType{Name: firstLetterToLower(t.Name)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// fun run:热更新开发循环——改代码自动重编译重启。
|
||||||
|
// 纯标准库实现:轮询 mtime 检测变更(400ms),先编译成功再切换进程,
|
||||||
|
// 编译失败保留旧进程继续跑
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cmdRun(args []string) {
|
||||||
|
pkg := "."
|
||||||
|
var childArgs []string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
if args[i] == "-p" && i+1 < len(args) {
|
||||||
|
pkg = args[i+1]
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if args[i] == "--" {
|
||||||
|
childArgs = append(childArgs, args[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(args[i], "-p") && len(args[i]) > 2 {
|
||||||
|
pkg = args[i][2:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childArgs = append(childArgs, args[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
binPath := filepath.Join(os.TempDir(), fmt.Sprintf("fun-run-%d", os.Getpid()))
|
||||||
|
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
var child *exec.Cmd
|
||||||
|
var childDone chan struct{}
|
||||||
|
restart := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
// 初始编译
|
||||||
|
if !build(pkg, binPath) {
|
||||||
|
fmt.Println("fun: 初始编译失败,等待文件变更后重试")
|
||||||
|
} else {
|
||||||
|
child, childDone = spawn(binPath, childArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
go watchLoop(pkg, restart)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sigs:
|
||||||
|
fmt.Println("\nfun: 退出")
|
||||||
|
killChild(child, childDone)
|
||||||
|
return
|
||||||
|
case <-restart:
|
||||||
|
fmt.Println("fun: 检测到变更,重新编译...")
|
||||||
|
if !build(pkg, binPath) {
|
||||||
|
fmt.Println("fun: 编译失败,旧进程继续运行")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
killChild(child, childDone)
|
||||||
|
child, childDone = spawn(binPath, childArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func build(pkg, binPath string) bool {
|
||||||
|
cmd := goCommand("build", pkg, "-o", binPath)
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spawn(binPath string, args []string) (*exec.Cmd, chan struct{}) {
|
||||||
|
cmd := exec.Command(binPath, args...)
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||||
|
// 独立进程组:杀父进程时连带子进程(业务可能 fork 了 worker)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
done := make(chan struct{})
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "fun: 启动失败:", err)
|
||||||
|
close(done)
|
||||||
|
return nil, done
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "fun: 进程退出: %v\n", err)
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
return cmd, done
|
||||||
|
}
|
||||||
|
|
||||||
|
func killChild(child *exec.Cmd, done chan struct{}) {
|
||||||
|
if child == nil || child.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syscall.Kill(-child.Process.Pid, syscall.SIGTERM)
|
||||||
|
if done == nil {
|
||||||
|
done = make(chan struct{})
|
||||||
|
go func() { child.Wait(); close(done) }()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
syscall.Kill(-child.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipDirs = map[string]bool{
|
||||||
|
".git": true, "vendor": true, "node_modules": true, ".fun": true,
|
||||||
|
"gen": true, "testdata": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot 收集监控文件的 (路径, mtime)
|
||||||
|
func snapshot(root string) map[string]int64 {
|
||||||
|
out := map[string]int64{}
|
||||||
|
filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if d.IsDir() {
|
||||||
|
if path != root && skipDirs[name] {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
if ext == ".go" || name == "go.mod" || name == "go.sum" {
|
||||||
|
if info, err := d.Info(); err == nil {
|
||||||
|
out[path] = info.ModTime().UnixNano()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffSnapshot(a, b map[string]int64) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for k, v := range a {
|
||||||
|
if b[k] != v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchLoop(root string, changed chan struct{}) {
|
||||||
|
last := snapshot(root)
|
||||||
|
var pending bool
|
||||||
|
var pendingAt time.Time
|
||||||
|
for {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
cur := snapshot(root)
|
||||||
|
if !diffSnapshot(last, cur) {
|
||||||
|
// 静默 300ms 后触发(编辑器分多次写盘时合并)
|
||||||
|
if pending && time.Since(pendingAt) > 300*time.Millisecond {
|
||||||
|
pending = false
|
||||||
|
select {
|
||||||
|
case changed <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last = cur
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last = cur
|
||||||
|
if !pending {
|
||||||
|
pending = true
|
||||||
|
pendingAt = time.Now()
|
||||||
|
} else {
|
||||||
|
pendingAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedKeys 调试用(保留给未来扩展)
|
||||||
|
func sortedKeys(m map[string]int64) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
+1026
File diff suppressed because it is too large
Load Diff
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"services":[{"name":"AlphaGenSvc","methods":[{"name":"Alpha","dto":{"kind":"struct","name":"AlphaGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]},"returns":{"kind":"struct","name":"ZebraGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]}},{"name":"Ping","void":true},{"name":"Zebra","dto":{"kind":"struct","name":"ZebraGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]},"returns":{"kind":"struct","name":"AlphaGenDto","fields":[{"name":"Value","type":{"kind":"string","name":"string"}}]}}]},{"name":"MixedGenSvc","methods":[{"name":"Request","returns":{"kind":"string","name":"string"}},{"name":"Stream","isStream":true}]},{"name":"ZebraGenSvc","methods":[{"name":"Watch","isStream":true}]}]}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"services":[{"name":"ChatSvc","methods":[{"name":"Ask","isStream":true,"dto":{"kind":"struct","name":"AskDto","fields":[{"name":"Prompt","type":{"kind":"string","name":"string"}}]},"returns":{"kind":"string","name":"string"}},{"name":"Chat","isStream":true,"dto":{"kind":"struct","name":"ChatDto","fields":[{"name":"Prompt","type":{"kind":"string","name":"string"}}]}}]},{"name":"OrderSvc","methods":[{"name":"Cancel","isStream":false,"void":true,"dto":{"kind":"struct","name":"CancelDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}},{"name":"Reason","type":{"kind":"string","name":"string","optional":true}}]}},{"name":"Create","isStream":false,"dto":{"kind":"struct","name":"CreateOrderDto","fields":[{"name":"Sku","type":{"kind":"string","name":"string"}},{"name":"Count","type":{"kind":"int","name":"int64"}},{"name":"Note","type":{"kind":"string","name":"string","optional":true}},{"name":"Status","type":{"kind":"enum","name":"OrderStatus","optional":true,"names":["Pending","Paid","Shipped"],"displayNames":["待支付","已支付","已发货"]}},{"name":"Tags","type":{"kind":"slice","elem":{"kind":"string","name":"string"}}}]},"returns":{"kind":"struct","name":"OrderDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}},{"name":"Status","type":{"kind":"enum","name":"OrderStatus","names":["Pending","Paid","Shipped"],"displayNames":["待支付","已支付","已发货"]}},{"name":"Amount","type":{"kind":"string","name":"string"}},{"name":"Items","type":{"kind":"slice","elem":{"kind":"string","name":"string"}}}]}},{"name":"Get","isStream":false,"dto":{"kind":"struct","name":"GetOrderDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}}]},"returns":{"kind":"struct","name":"OrderDto","fields":[{"name":"Id","type":{"kind":"int","name":"int64"}},{"name":"Status","type":{"kind":"enum","name":"OrderStatus","names":["Pending","Paid","Shipped"],"displayNames":["待支付","已支付","已发货"]}},{"name":"Amount","type":{"kind":"string","name":"string"}},{"name":"Items","type":{"kind":"slice","elem":{"kind":"string","name":"string"}}}]}}]}]}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 解析 fun 生成的 TypeScript 客户端产物,重建文档站所需的元数据。
|
||||||
|
// 生成物即元数据:fun 自己的反射保证类型准确,CLI 只做只读解析
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reServiceClass = regexp.MustCompile(`(?m)^export default class (\w+) \{`)
|
||||||
|
reStreamMethod = regexp.MustCompile(`(?m)^ async (\w+)\((?:dto:\s?([^,]+), )?onMessage: \(data: (.+?)\) => unknown`)
|
||||||
|
reDataMethod = regexp.MustCompile(`(?m)^ async (\w+)\((?:dto:\s?([^,]+), )?options\?: RequestOptions\): Promise<result<(.+?)>>`)
|
||||||
|
reInterface = regexp.MustCompile(`(?s)export default interface (\w+) \{([^}]*)\}`)
|
||||||
|
reEnum = regexp.MustCompile(`(?s)enum (\w+) \{([^}]*)\}`)
|
||||||
|
reEnumMember = regexp.MustCompile(`(?m)^\s+(\w+),?\s*$`)
|
||||||
|
reField = regexp.MustCompile(`(?m)^\s+([\w?]+):(.+)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTsDir 解析 GenTs 输出目录(ts/ 子目录),重建 FunMeta。
|
||||||
|
// 两遍解析:先收集全部 enum/interface 原始声明,再解析字段引用,
|
||||||
|
// 避免文件顺序(字母序)导致前置类型解析不到
|
||||||
|
func parseTsDir(tsDir string) (*FunMeta, error) {
|
||||||
|
if _, err := os.Stat(filepath.Join(tsDir, "client.ts")); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s 不是 fun 生成的 ts 目录(缺 client.ts)", tsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
enums := map[string][]string{}
|
||||||
|
type rawField struct {
|
||||||
|
Name string
|
||||||
|
TsType string
|
||||||
|
Optional bool
|
||||||
|
}
|
||||||
|
ifaceRaw := map[string][]rawField{}
|
||||||
|
ifaceNode := map[string]*MetaType{}
|
||||||
|
var serviceFiles []string
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(tsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".ts") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Name() == "client.ts" || e.Name() == "fun.ts" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(tsDir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := string(body)
|
||||||
|
if reServiceClass.MatchString(src) {
|
||||||
|
serviceFiles = append(serviceFiles, src)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := reEnum.FindStringSubmatch(src); m != nil {
|
||||||
|
var names []string
|
||||||
|
for _, line := range reEnumMember.FindAllStringSubmatch(m[2], -1) {
|
||||||
|
names = append(names, line[1])
|
||||||
|
}
|
||||||
|
enums[m[1]] = names
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := reInterface.FindStringSubmatch(src); m != nil {
|
||||||
|
var fields []rawField
|
||||||
|
for _, f := range reField.FindAllStringSubmatch(m[2], -1) {
|
||||||
|
name, tsType := f[1], strings.TrimSpace(f[2])
|
||||||
|
fields = append(fields, rawField{
|
||||||
|
Name: strings.TrimSuffix(name, "?"),
|
||||||
|
TsType: tsType,
|
||||||
|
Optional: strings.HasSuffix(name, "?"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ifaceRaw[m[1]] = fields
|
||||||
|
ifaceNode[m[1]] = &MetaType{Kind: "struct", Name: m[1]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二遍:填充 interface 字段(引用此时全部可见)
|
||||||
|
resolve := func(tsType string) *MetaType { return parseTsType(tsType, ifaceNode, enums) }
|
||||||
|
for name, fields := range ifaceRaw {
|
||||||
|
node := ifaceNode[name]
|
||||||
|
for _, f := range fields {
|
||||||
|
ft := resolve(f.TsType)
|
||||||
|
if f.Optional {
|
||||||
|
ft.Optional = true
|
||||||
|
}
|
||||||
|
node.Fields = append(node.Fields, MetaField{Name: f.Name, Type: ft})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := &FunMeta{}
|
||||||
|
for _, src := range serviceFiles {
|
||||||
|
svcName := reServiceClass.FindStringSubmatch(src)[1]
|
||||||
|
svc := MetaService{Name: svcName}
|
||||||
|
for _, m := range reDataMethod.FindAllStringSubmatch(src, -1) {
|
||||||
|
mm := MetaMethod{Name: m[1], Returns: resolve(m[3])}
|
||||||
|
if m[3] == "void" {
|
||||||
|
mm.Void = true
|
||||||
|
mm.Returns = nil
|
||||||
|
}
|
||||||
|
if m[2] != "" {
|
||||||
|
mm.DTO = resolve(m[2])
|
||||||
|
}
|
||||||
|
svc.Methods = append(svc.Methods, mm)
|
||||||
|
}
|
||||||
|
for _, m := range reStreamMethod.FindAllStringSubmatch(src, -1) {
|
||||||
|
mm := MetaMethod{Name: m[1], IsStream: true}
|
||||||
|
if m[2] != "" {
|
||||||
|
mm.DTO = resolve(m[2])
|
||||||
|
}
|
||||||
|
if m[3] != "any" {
|
||||||
|
mm.Returns = resolve(m[3])
|
||||||
|
}
|
||||||
|
svc.Methods = append(svc.Methods, mm)
|
||||||
|
}
|
||||||
|
sortMetaService(&svc)
|
||||||
|
meta.Services = append(meta.Services, svc)
|
||||||
|
}
|
||||||
|
sortMeta(meta)
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTsType TS 类型串 → 元数据类型树
|
||||||
|
func parseTsType(s string, interfaces map[string]*MetaType, enums map[string][]string) *MetaType {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
// 流式 dto 工厂类型 "X | (() => X)" 取值类型部分
|
||||||
|
if i := strings.Index(s, " | ("); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
// fun v1.3.7 流式模板对 DTO 会多写一层 "dto:" 前缀,兼容剥离
|
||||||
|
s = strings.TrimPrefix(s, "dto:")
|
||||||
|
t := &MetaType{}
|
||||||
|
if strings.HasSuffix(s, " | null") {
|
||||||
|
t.Optional = true
|
||||||
|
s = strings.TrimSuffix(s, " | null")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(s, "[]") {
|
||||||
|
t.Kind = "slice"
|
||||||
|
t.Elem = parseTsType(strings.TrimSuffix(s, "[]"), interfaces, enums)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "number":
|
||||||
|
t.Kind, t.Name = "int", "int64"
|
||||||
|
case "boolean":
|
||||||
|
t.Kind, t.Name = "bool", "bool"
|
||||||
|
case "string":
|
||||||
|
t.Kind, t.Name = "string", "string"
|
||||||
|
case "any":
|
||||||
|
t.Kind, t.Name = "string", "any"
|
||||||
|
default:
|
||||||
|
if names, ok := enums[s]; ok {
|
||||||
|
t.Kind, t.Name = "enum", s
|
||||||
|
t.Names = names
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if it, ok := interfaces[s]; ok {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
t.Kind, t.Name = "struct", s // 定义在其他文件(如跨包),仅名字引用
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortMetaService(svc *MetaService) {
|
||||||
|
for i := 1; i < len(svc.Methods); i++ {
|
||||||
|
for j := i; j > 0 && svc.Methods[j].Name < svc.Methods[j-1].Name; j-- {
|
||||||
|
svc.Methods[j], svc.Methods[j-1] = svc.Methods[j-1], svc.Methods[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortMeta(meta *FunMeta) {
|
||||||
|
for i := 1; i < len(meta.Services); i++ {
|
||||||
|
for j := i; j > 0 && meta.Services[j].Name < meta.Services[j-1].Name; j-- {
|
||||||
|
meta.Services[j], meta.Services[j-1] = meta.Services[j-1], meta.Services[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 渲染→解析回环:用渲染器从元数据生成 TS,再解析回元数据,验证解析器覆盖
|
||||||
|
// 枚举/可空/流式/嵌套等全部特性
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTsRenderParseRoundTrip(t *testing.T) {
|
||||||
|
meta, err := loadMeta(filepath.Join("testdata", "demo.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := fileSet{}
|
||||||
|
renderTs{}.renderAll(meta, files)
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := writeFiles(dir, files); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := parseTsDir(filepath.Join(dir, "ts"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ask, create *MetaMethod
|
||||||
|
var order, chat *MetaService
|
||||||
|
for i := range parsed.Services {
|
||||||
|
switch parsed.Services[i].Name {
|
||||||
|
case "orderSvc":
|
||||||
|
order = &parsed.Services[i]
|
||||||
|
case "chatSvc":
|
||||||
|
chat = &parsed.Services[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if order == nil || chat == nil {
|
||||||
|
t.Fatalf("services missing: %+v", parsed.Services)
|
||||||
|
}
|
||||||
|
for i := range order.Methods {
|
||||||
|
if order.Methods[i].Name == "create" {
|
||||||
|
create = &order.Methods[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range chat.Methods {
|
||||||
|
if chat.Methods[i].Name == "ask" {
|
||||||
|
ask = &chat.Methods[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ask == nil || !ask.IsStream || ask.Returns == nil || ask.Returns.Kind != "string" {
|
||||||
|
t.Errorf("chatSvc.ask 解析错误: %+v", ask)
|
||||||
|
}
|
||||||
|
if ask.DTO == nil || len(ask.DTO.Fields) != 1 || ask.DTO.Fields[0].Name != "prompt" {
|
||||||
|
t.Errorf("chatSvc.ask DTO 解析错误: %+v", ask.DTO)
|
||||||
|
}
|
||||||
|
if create == nil || create.Void {
|
||||||
|
t.Errorf("orderSvc.create 解析错误: %+v", create)
|
||||||
|
}
|
||||||
|
if create == nil || create.DTO == nil || len(create.DTO.Fields) != 5 {
|
||||||
|
t.Fatalf("createOrderDto 字段数错误: %+v", create.DTO)
|
||||||
|
}
|
||||||
|
want := map[string]struct {
|
||||||
|
kind string
|
||||||
|
opt bool
|
||||||
|
}{
|
||||||
|
"sku": {"string", false},
|
||||||
|
"count": {"int", false},
|
||||||
|
"note": {"string", true},
|
||||||
|
"status": {"enum", true},
|
||||||
|
"tags": {"slice", false},
|
||||||
|
}
|
||||||
|
for _, f := range create.DTO.Fields {
|
||||||
|
w, ok := want[f.Name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("多余字段: %s", f.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Type.Kind != w.kind || f.Type.Optional != w.opt {
|
||||||
|
t.Errorf("字段 %s: kind=%s opt=%v, 期望 %s/%v", f.Name, f.Type.Kind, f.Type.Optional, w.kind, w.opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = os.Environ
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user