From bb3c68f56c80129af1076d730e3f5c44f1b57542 Mon Sep 17 00:00:00 2001 From: Star Date: Wed, 19 Aug 2026 16:12:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=A2=9E=E5=8A=A0=E6=97=A0?= =?UTF-8?q?=E7=8A=B6=E6=80=81=20Action=20YAML=20=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=EF=BC=88by=20AI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++ README.md | 1 + TEST.md | 8 +++- go.mod | 12 +++--- go.sum | 30 ++++---------- yaml.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++ yaml_test.go | 53 +++++++++++++++++++++++++ 7 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 yaml.go create mode 100644 yaml_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4473b72..5d71dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## v1.5.8 (2026-08-19) +- **Action YAML**: 增加无状态解析、保注释 Token 补丁和 JSON 到 YAML 转换能力,不绑定存储或内置第三方服务定义。 +- **依赖对齐**: 升级 `file`、`id`、`rand` 和 `shell` 到当前稳定补丁版本。 + ## v1.5.7 (2026-08-17) - **低代码结果投影**: 动态 Action 可通过 `resultMode: data` 让 JavaScript `api.Call` 直接返回业务数据,并补齐 `ok/code/error`。 - **继承一致性**: 结果投影读取完整的 Action 继承配置,子 Action 无需重复声明。 diff --git a/README.md b/README.md index 21be0bf..60ba769 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ go get apigo.cc/go/api * `RegisterAction` / `RemoveAction`:动态注册和热更新数据驱动的 Action。 * `RegisterJSSigner` / `RemoveSigner`:管理 JavaScript Signer。 * `RegisterJSFilter` / `RemoveFilter`:管理可复用的 JavaScript 请求/响应 Filter。 +* `ParseActionYAML` / `PatchActionTokens` / `ConvertActionJSONToYAML`:无状态解析、保注释补丁和旧格式转换;不内置任何供应商 Action。 ## 🔒 安全性 (Ultimate Memory Safety) diff --git a/TEST.md b/TEST.md index cb0c1f2..9c8d229 100644 --- a/TEST.md +++ b/TEST.md @@ -39,11 +39,15 @@ 验证 `resultMode: data` 仅向 JavaScript 返回业务数据与 `ok/code/error`,不会泄露 HTTP Headers;同时验证子 Action 能继承父 Action 的投影策略。 +### 9. Action YAML (`TestParseActionYAML`, `TestPatchActionTokensPreservesDocument`, `TestConvertActionJSONToYAML`) + +验证 Action YAML 的对象解析、非对象拒绝、保留注释与字段顺序的 Token 补丁,以及不注入供应商默认值的 JSON 到 YAML 转换。 + ## ⏱ 性能基准测试 (Benchmark) 使用 `go test -bench=. ./...` 评估框架调用阶段的开销。 > **基准**: Darwin / Apple M3 Max -* `BenchmarkCallEngineLogic-16`:约 **115.4 ns/op**, **80 B/op**, **2 allocs/op**。 +* `BenchmarkCallEngineLogic-16`:约 **115.5 ns/op**, **80 B/op**, **2 allocs/op**。 该指标证明引擎的参数合并、注入及校验流程具有极高的运行效率和极小的内存逃逸。 ## 🚀 运行测试 @@ -57,5 +61,5 @@ go test -bench=. ./... ``` --- -最后测试日期:2026-08-17 +最后测试日期:2026-08-19 状态:PASS diff --git a/go.mod b/go.mod index 84db104..9dc7a34 100644 --- a/go.mod +++ b/go.mod @@ -9,18 +9,18 @@ require ( apigo.cc/go/encoding v1.5.6 apigo.cc/go/http v1.5.4 apigo.cc/go/jsmod v1.5.3 + apigo.cc/go/log v1.5.9 apigo.cc/go/safe v1.5.3 + gopkg.in/yaml.v3 v3.0.1 ) require ( - apigo.cc/go/file v1.5.5 // indirect - apigo.cc/go/id v1.5.4 // indirect - apigo.cc/go/log v1.5.9 // indirect - apigo.cc/go/rand v1.5.3 // indirect - apigo.cc/go/shell v1.5.3 // indirect + apigo.cc/go/file v1.5.6 // indirect + apigo.cc/go/id v1.5.7 // indirect + apigo.cc/go/rand v1.5.4 // indirect + apigo.cc/go/shell v1.5.5 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1b34366..9a1c175 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,27 @@ -apigo.cc/go/cast v1.5.3 h1:jk6VX0rGFhjKtfPhsaV6IKYpiGmORRk9qPTtuNS53tw= -apigo.cc/go/cast v1.5.3/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= apigo.cc/go/cast v1.5.5 h1:DMbfK3uPhPjRaXutj3StIZIkfjFIATSXfuAOeNOd4Fw= apigo.cc/go/cast v1.5.5/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= -apigo.cc/go/config v1.5.3 h1:peq1FM2xO+vzPHJf8Dwg3DXm8PtFQMfTFKQj6fpoG7A= -apigo.cc/go/config v1.5.3/go.mod h1:ZiOAjWa1mQIzszaJZN+kO6YU4GXreng+NxkcK/TAkqQ= apigo.cc/go/config v1.5.4 h1:1c/OarGwbz3+6ikurE+a6LJLjtzXvGvbxw3HV/Nk54M= apigo.cc/go/config v1.5.4/go.mod h1:oN+D2F8ETIyqKp+Yu8R4PRQlUoqR44o35jAHwLrrAq0= -apigo.cc/go/crypto v1.5.3 h1:2JUHC2cgR2zrnn36EzwkUAdxmmTXAA/8yTNo+2X1mPE= -apigo.cc/go/crypto v1.5.3/go.mod h1:PheYKHEXmoEFI1AK5PpY1borQWcRlkkSaWncT3cWbhE= apigo.cc/go/crypto v1.5.5 h1:YQHumieqviNGMhwoxDtMuUdGWAQiGrkVhz9hZRHOhWs= apigo.cc/go/crypto v1.5.5/go.mod h1:z/FXt0HE7fSMJKF2MF7jY2fSjlHUYYaSffETJ7ZzJmE= -apigo.cc/go/encoding v1.5.4 h1:Fk8TrveZATyy8SHukC4ZiqdTSp+QIfsRHtt55xmMK7w= -apigo.cc/go/encoding v1.5.4/go.mod h1:dShEsZ3gKqBINz7TSOYf4e7/fBCqCY9VzlenoGUQUFM= apigo.cc/go/encoding v1.5.6 h1:v02swVfbFGidD4QcX2ktuHHbCjdSbOB85fhzAXay+7M= apigo.cc/go/encoding v1.5.6/go.mod h1:Big9q1Zwy4071dXtnrQ3SJDzfa/G7/A60KE/5+M//P8= -apigo.cc/go/file v1.5.5 h1:/+HmDumLu6Qk2KuQL63M9lpgzHTDL+QJ8dStOl7e9gs= -apigo.cc/go/file v1.5.5/go.mod h1:xRVNhctvqOKeBemmcRW/BQfgkc3B+vT/UZVdSc7duUo= -apigo.cc/go/http v1.5.3 h1:nvJh9bqPPcPRv6p8WEw7bJAd0UC+r2zvQA8/QioVLTQ= -apigo.cc/go/http v1.5.3/go.mod h1:cFrPK61y9f1PrsNSJscZT/QVOgkT15o9OP7O8cuMb8Q= +apigo.cc/go/file v1.5.6 h1:Y7w3Tyu4e16VuED7rF2pzba+dzGE+hjDnBlIPVHIfzA= +apigo.cc/go/file v1.5.6/go.mod h1:9sdW4ylSOA0HWc8Yt8qdnmMf6nn5SUEmjoPKyXpYXIQ= apigo.cc/go/http v1.5.4 h1:Tm95WMsXyazFnaaPopdMXv1k9TiaPenLxDQ40WCrsEQ= apigo.cc/go/http v1.5.4/go.mod h1:cFrPK61y9f1PrsNSJscZT/QVOgkT15o9OP7O8cuMb8Q= -apigo.cc/go/id v1.5.4 h1:D1Zx9gEZhOgdTgZ4SdmPImhpc9xGiOA33Y+j2MkstzQ= -apigo.cc/go/id v1.5.4/go.mod h1:hCTQq+KC1ALWe1FpPERf+W4B6FSulg9FAgOUJDDySiY= +apigo.cc/go/id v1.5.7 h1:Y5Sx6sQBCAdYMCQPTjODZyGMMd1+WRWCy2dWHVq11XQ= +apigo.cc/go/id v1.5.7/go.mod h1:fugudFBqfVNakfm91zZzuzU0P4PULzo6sylB8hRMqxA= apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU= apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= -apigo.cc/go/log v1.5.8 h1:/IYtGPWhRjT3OayylDIphkWZIQbpLjqVeSnFEiD3Dy0= -apigo.cc/go/log v1.5.8/go.mod h1:HfFPANMYxJx197SSTXB21Pgxcz/gGqPP8nlSErgd5WE= apigo.cc/go/log v1.5.9 h1:g8JehZrpVJyesesv+JFig5szEELsO4UjShsjlEihCfc= apigo.cc/go/log v1.5.9/go.mod h1:b4f/UB5Kk7oiFtkXvG4GVilmHGygWgxELRAdEZrIyfs= -apigo.cc/go/rand v1.5.3 h1:O4bPIwyaOWEBCr0nL9A4G4qG48AqiGTCzfPeckm3Ius= -apigo.cc/go/rand v1.5.3/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4= -apigo.cc/go/safe v1.5.2 h1:EnuEOW/SGwf/5A0nw9LnqfKJE071+TIc6ez8HI9R9Lg= -apigo.cc/go/safe v1.5.2/go.mod h1:2GqCCLLGex4OAhdET3iBWm1R+LIYtmTrvHP8W0iESSw= +apigo.cc/go/rand v1.5.4 h1:eessFBsKQuoOYdzrStldOGw9f4HqbeO87X95bI4jIBQ= +apigo.cc/go/rand v1.5.4/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4= apigo.cc/go/safe v1.5.3 h1:9p/BmdlVWLbekpKByZIFC09Qn8Wdhik2eINiwunBxPs= apigo.cc/go/safe v1.5.3/go.mod h1:Ay8kEPL76DeXH4ifsVTc/3/sfGHlWLQjAp4vi7GA9AI= -apigo.cc/go/shell v1.5.3 h1:pI+u12sy6upoygq+1XXqUlvUboBfH4Q52jRpoJFv56A= -apigo.cc/go/shell v1.5.3/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY= +apigo.cc/go/shell v1.5.5 h1:sf1QZiL7IHkoOLdrYX1qT4JWIlYZIAAYGJJXorjscB4= +apigo.cc/go/shell v1.5.5/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/yaml.go b/yaml.go new file mode 100644 index 0000000..1e5eedb --- /dev/null +++ b/yaml.go @@ -0,0 +1,109 @@ +package api + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// ParseActionYAML parses a data-driven Action definition without applying +// product-specific defaults or registering it globally. +func ParseActionYAML(source string) (map[string]any, error) { + definition := map[string]any{} + if err := yaml.Unmarshal([]byte(source), &definition); err != nil { + return nil, fmt.Errorf("invalid Action YAML: %w", err) + } + if definition == nil { + return nil, fmt.Errorf("Action YAML must be an object") + } + return definition, nil +} + +// PatchActionTokens replaces the top-level tokens field while preserving the +// rest of the YAML document, including comments and field order. +func PatchActionTokens(source string, tokens []string) (string, error) { + document, root, err := parseActionYAMLDocument(source) + if err != nil { + return "", err + } + index := yamlMappingIndex(root, "tokens") + if len(tokens) == 0 { + if index >= 0 { + root.Content = append(root.Content[:index], root.Content[index+2:]...) + } + } else { + sequence := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Style: yaml.FlowStyle} + for _, token := range tokens { + sequence.Content = append(sequence.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: token}) + } + if index >= 0 { + root.Content[index+1] = sequence + } else { + root.Content = append(root.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "tokens"}, sequence) + } + } + return encodeActionYAML(document) +} + +// ConvertActionJSONToYAML converts a legacy JSON Action document to readable +// block-style YAML. It does not add defaults or modify the Action semantics. +func ConvertActionJSONToYAML(source string) (string, error) { + document, root, err := parseActionYAMLDocument(source) + if err != nil { + return "", err + } + useYAMLBlockStyle(root) + root.HeadComment = "API Action configuration. Field order and comments are preserved." + return encodeActionYAML(document) +} + +func parseActionYAMLDocument(source string) (*yaml.Node, *yaml.Node, error) { + var document yaml.Node + if err := yaml.Unmarshal([]byte(source), &document); err != nil { + return nil, nil, fmt.Errorf("invalid Action YAML: %w", err) + } + if len(document.Content) == 0 || document.Content[0].Kind != yaml.MappingNode { + return nil, nil, fmt.Errorf("Action YAML must be an object") + } + return &document, document.Content[0], nil +} + +func encodeActionYAML(document *yaml.Node) (string, error) { + var output bytes.Buffer + encoder := yaml.NewEncoder(&output) + encoder.SetIndent(2) + if err := encoder.Encode(document); err != nil { + return "", err + } + _ = encoder.Close() + return strings.TrimRight(output.String(), "\n") + "\n", nil +} + +func yamlMappingIndex(root *yaml.Node, key string) int { + for index := 0; index+1 < len(root.Content); index += 2 { + if root.Content[index].Value == key { + return index + } + } + return -1 +} + +func useYAMLBlockStyle(node *yaml.Node) { + if node.Kind == yaml.MappingNode || node.Kind == yaml.SequenceNode { + node.Style = 0 + } + if node.Kind == yaml.ScalarNode { + node.Style = 0 + } + if node.Kind == yaml.MappingNode { + for index := 0; index < len(node.Content); index += 2 { + node.Content[index].Style = 0 + } + } + for _, child := range node.Content { + useYAMLBlockStyle(child) + } +} diff --git a/yaml_test.go b/yaml_test.go new file mode 100644 index 0000000..5b2184a --- /dev/null +++ b/yaml_test.go @@ -0,0 +1,53 @@ +package api_test + +import ( + "strings" + "testing" + + "apigo.cc/go/api" +) + +func TestParseActionYAML(t *testing.T) { + definition, err := api.ParseActionYAML("name: sample\nmethod: POST\nstream: false\n") + if err != nil { + t.Fatal(err) + } + if definition["name"] != "sample" || definition["method"] != "POST" || definition["stream"] != false { + t.Fatalf("unexpected definition: %#v", definition) + } + if _, err = api.ParseActionYAML("- invalid\n"); err == nil { + t.Fatal("sequence Action must be rejected") + } +} + +func TestPatchActionTokensPreservesDocument(t *testing.T) { + source := "# action comment\nname: sample\n# endpoint comment\nurl: https://example.com\ntokens: [old]\n" + updated, err := api.PatchActionTokens(source, []string{"one", "two"}) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"# action comment", "# endpoint comment", "name: sample", "tokens: [one, two]"} { + if !strings.Contains(updated, expected) { + t.Fatalf("patched YAML does not contain %q:\n%s", expected, updated) + } + } + removed, err := api.PatchActionTokens(updated, nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(removed, "tokens:") { + t.Fatalf("tokens field was not removed:\n%s", removed) + } +} + +func TestConvertActionJSONToYAML(t *testing.T) { + converted, err := api.ConvertActionJSONToYAML(`{"name":"sample","headers":{"Accept":"application/json"},"filters":["clean"]}`) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"# API Action configuration", "name: sample", "headers:\n Accept: application/json", "filters:\n - clean"} { + if !strings.Contains(converted, expected) { + t.Fatalf("converted YAML does not contain %q:\n%s", expected, converted) + } + } +}