refactor: 入口移至 cmd/fun,go install 后命令名为 fun
This commit is contained in:
@@ -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>")
|
||||
}
|
||||
}
|
||||
+130
@@ -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>
|
||||
Executable
BIN
Binary file not shown.
+186
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
+196
@@ -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
|
||||
}
|
||||
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"}}}]}}]}]}
|
||||
@@ -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