feat: add typed error message templates and stable replay snapshots

This commit is contained in:
2026-09-24 15:05:08 +08:00
parent 2041e7f661
commit 6e3f025aad
32 changed files with 1570 additions and 99 deletions

View File

@@ -23,6 +23,8 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api
`v0.10.0-preview` 新增 `errorCatalog`:集中配置业务码、提示和 HTTP 状态,支持默认 HTTP 200 与单条覆盖,生成函数、路由策略和 OpenAPI 共用一份定义。语法及配置方式见 [统一错误目录](../../errors/README.md)。
`v0.11.0-preview` 增加有序模板参数,生成带类型的错误函数和具体文档示例;示例见 [errorParams.api](../../examples/contracts/errorParams.api)。新生成代码需要同版或更新框架。
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
## 日志与对象存储按需装配
@@ -77,8 +79,8 @@ Handler / Service / Repository / Model 与模块注册入口。模块名为 lowe
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview
go-omni new --framework-version v0.10.0-preview --module example.com/billing ./billing
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.0-preview
go-omni new --framework-version v0.11.0-preview --module example.com/billing ./billing
cd billing
go mod tidy
go run ./cmd/server

View File

@@ -53,6 +53,7 @@ func prepareMetadata(original *Spec) (*Spec, error) {
normalized := registry.Catalog()
for i := range spec.Errors {
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params
}
if err := validateErrorPolicies(&spec); err != nil {
return nil, err
@@ -140,6 +141,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
}
doc["components"].(map[string]any)["securitySchemes"] = schemes
definitions := map[string]ErrorDefinition{}
registry := apierrors.MustRegistry(errorCatalog(spec, nil))
for _, e := range spec.Errors {
definitions[e.Name] = e
}
@@ -225,8 +227,10 @@ func decorateDocument(doc map[string]any, spec *Spec) {
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs {
value := policy.Value(responseResult(e))
examples[e.Name] = map[string]any{"summary": e.Message, "value": value}
result := responseResult(e)
result.Message = registry.Example(e.Code).Message
value := policy.Value(result)
examples[e.Name] = map[string]any{"summary": result.Message, "value": value}
codeKey := fmt.Sprint(value[cfg.CodeField])
if !seenCodes[codeKey] {
codes = append(codes, value[cfg.CodeField])

View File

@@ -4,18 +4,21 @@ import (
"fmt"
"strconv"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// ErrorDefinition declares a public, stable error. Generated constructors return
// a fresh error each time so WithCause cannot mutate a shared singleton.
type ErrorDefinition struct {
Name string `json:"name"`
BusinessCode any `json:"businessCode,omitempty"`
Catalog string `json:"catalog,omitempty"`
Status int `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
Position string `json:"-"`
Name string `json:"name"`
BusinessCode any `json:"businessCode,omitempty"`
Params []apierrors.Parameter `json:"params,omitempty"`
Catalog string `json:"catalog,omitempty"`
Status int `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
Position string `json:"-"`
}
type AuthDefinition struct {
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string

View File

@@ -49,10 +49,11 @@ func (p *parser) parseErrorCatalog() error {
}
}
type entry struct {
Key string `json:"key"`
Code any `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
Key string `json:"key"`
Code any `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
Params []apierrors.Parameter `json:"params"`
}
var entries map[string]entry
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
@@ -74,7 +75,7 @@ func (p *parser) parseErrorCatalog() error {
if e.Status == 0 {
e.Status = defaultStatus
}
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Catalog: name, Position: pos})
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Catalog: name, Position: pos})
}
return nil
}
@@ -89,7 +90,7 @@ func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
if names != nil && !selected[e.Name] {
continue
}
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message}
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params}
}
return c
}

View File

@@ -84,12 +84,17 @@ func TestErrorCatalogDocumentAndValidation(t *testing.T) {
}
func TestErrorCatalogConsumer(t *testing.T) {
testCatalogConsumer(t, "errors.api", "errorCatalog")
}
func testCatalogConsumer(t *testing.T, apiFile, fixtureName string) {
t.Helper()
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
root := frameworkRoot(t)
dir := filepath.Join(t.TempDir(), "consumer")
opts := Options{API: filepath.Join(root, "examples/contracts/errors.api"), Dir: dir, Module: "example.com/errorCatalog", FrameworkDir: root}
opts := Options{API: filepath.Join(root, "examples/contracts", apiFile), Dir: dir, Module: "example.com/errorCatalog", FrameworkDir: root}
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(check bool) {
t.Helper()
@@ -113,7 +118,7 @@ func TestErrorCatalogConsumer(t *testing.T) {
}
generate(false)
fixture := readFile(t, "testdata/errorCatalog/consumer_test.go")
fixture := readFile(t, filepath.Join("testdata", fixtureName, "consumer_test.go"))
writeFile(t, filepath.Join(dir, "consumer_test.go"), fixture)
generate(false)
generate(true)

View File

@@ -0,0 +1,86 @@
package apigen
import (
"path/filepath"
"strings"
"testing"
)
func TestErrorParamsConsumer(t *testing.T) { testCatalogConsumer(t, "errorParams.api", "errorParams") }
func TestErrorParamsDocumentAndValidation(t *testing.T) {
path := filepath.Join(frameworkRoot(t), "examples/contracts/errorParams.api")
source := readFile(t, path)
spec, err := Parse(path)
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
definitions := doc["x-omni-errors"].([]any)
found := false
for _, value := range definitions {
e := value.(map[string]any)
if e["name"] == "TextTooLong" {
found = true
params := e["params"].([]any)
if len(params) != 2 || params[0].(map[string]any)["name"] != "label" || params[1].(map[string]any)["type"] != "int" || e["message"] != "{label}不能超过{max}个字符" {
t.Fatal(e)
}
}
}
if !found {
t.Fatal("missing template definition")
}
for _, path := range []string{"/token", "/raw"} {
method := "post"
if path == "/raw" {
method = "get"
}
response := operation(t, doc, path, method)["responses"].(map[string]any)["200"].(map[string]any)
media := response["content"].(map[string]any)["application/json"].(map[string]any)
value := media["examples"].(map[string]any)["TextTooLong"].(map[string]any)["value"].(map[string]any)
if value["msg"] != "企业名称不能超过100个字符" || value["code"] != float64(400002) {
t.Fatal(value)
}
if _, ok := value["data"]; !ok {
t.Fatal("data null omitted", value)
}
if len(media["schema"].(map[string]any)["anyOf"].([]any)) != 2 {
t.Fatal("success schema lost")
}
}
for _, change := range [][2]string{
{`"{label}不能超过{max}个字符"`, `"{label}不能超过{missing}个字符"`},
{`"type": "int"`, `"type": "unknown"`},
{`"example": 100`, `"example": "100"`},
{`"name": "max"`, `"name": "label"`},
{`"name": "max"`, `"name": "type"`},
{`successCode: 0`, `successCode: 0\n messages: {"TEXT_TOO_LONG":"{label}不能超过{max}个字符"}`},
} {
changed := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
s, err := parseText(t, changed)
if err == nil {
_, err = Compile(s)
}
if err == nil {
t.Fatal("accepted", change)
}
}
// Removing examples still produces a concrete, deterministic example.
noExamples := strings.ReplaceAll(strings.ReplaceAll(source, `, "example": "企业名称"`, ""), `, "example": 100`, "")
s, err := parseText(t, noExamples)
if err != nil {
t.Fatal(err)
}
c, err := Compile(s)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(c.Document), "label不能超过1个字符") {
t.Fatal("missing generated example")
}
}

View File

@@ -19,7 +19,7 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.10.0-preview"
const minimumVersion = "v0.11.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
@@ -140,13 +140,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
if !filepath.IsAbs(path) {
path = filepath.Join(directory, path)
}
if _, err := os.Stat(filepath.Join(path, "errors", "catalog.go")); err == nil {
if _, err := os.Stat(filepath.Join(path, "errors", "params.go")); err == nil {
localCurrent = true
}
}
}
if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates error catalog support; use %s or a current local replacement", minimumVersion)
return Output{}, fmt.Errorf("existing framework dependency predates error template parameter support; use %s or a current local replacement", minimumVersion)
}
}
if o.FrameworkDir != "" {
@@ -315,7 +315,21 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
content := "package apierrors\nimport(\"encoding/json\"; omnierrors " + strconv.Quote(frameworkModule+"/errors") + ")\n"
content += fmt.Sprintf("func Catalog()omnierrors.Catalog{var c omnierrors.Catalog;if err:=json.Unmarshal([]byte(%q),&c);err!=nil{panic(err)};return c}\nvar errorRegistry=omnierrors.MustRegistry(Catalog())\n", errorCatalogJSON(c.Spec, nil))
for _, e := range c.Spec.Errors {
content += fmt.Sprintf("const Code%s=%q\nfunc %s()*omnierrors.APIError{return errorRegistry.Resolve(omnierrors.ByCode(Code%s))}\n", e.Name, e.Code, e.Name, e.Name)
var args, values []string
for _, p := range e.Params {
name := p.Name
switch name {
case "string", "any", "omnierrors", "errorRegistry", "int", "int32", "int64", "float64", "bool":
name = "_" + name
}
args = append(args, name+" "+p.Type)
values = append(values, strconv.Quote(p.Name)+":"+name)
}
ref := fmt.Sprintf("omnierrors.ByCode(Code%s)", e.Name)
if len(args) > 0 {
ref += ".WithParams(map[string]any{" + strings.Join(values, ",") + "})"
}
content += fmt.Sprintf("const Code%s=%q\n// %s returns a fresh, resolved error declared in the API contract.\nfunc %s(%s)*omnierrors.APIError{return errorRegistry.Resolve(%s)}\n", e.Name, e.Code, e.Name, e.Name, strings.Join(args, ","), ref)
}
put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content)
}

View File

@@ -0,0 +1,149 @@
package consumer_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http/httptest"
"strings"
"sync"
"testing"
"example.com/errorCatalog/internal/apierrors"
"example.com/errorCatalog/internal/handler"
"example.com/errorCatalog/internal/servicecontext"
omni "git.yuntuo.hk/k2safe/go-omni"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestGeneratedTemplateErrors(t *testing.T) {
var document map[string]any
if err := json.Unmarshal([]byte(handler.OpenAPIDocument), &document); err != nil {
t.Fatal(err)
}
operation := document["paths"].(map[string]any)["/token"].(map[string]any)["post"].(map[string]any)
media := operation["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
example := media["examples"].(map[string]any)["TextTooLong"].(map[string]any)["value"].(map[string]any)
if example["msg"] != "企业名称不能超过100个字符" || example["code"] != float64(400002) {
t.Fatal(example)
}
if len(media["schema"].(map[string]any)["anyOf"].([]any)) != 2 {
t.Fatal("missing success/failure schema")
}
found := false
for _, raw := range document["x-omni-errors"].([]any) {
entry := raw.(map[string]any)
if entry["name"] == "TextTooLong" {
found = true
params := entry["params"].([]any)
if len(params) != 2 || params[0].(map[string]any)["type"] != "string" || params[1].(map[string]any)["type"] != "int" {
t.Fatal(params)
}
}
}
if !found {
t.Fatal("missing template metadata")
}
// Assigning to a typed function also verifies declaration order and Go types.
var typed func(string, int) *omnierrors.APIError = apierrors.TextTooLong
private := errors.New("private cause")
first := typed("企业名称", 100).WithCause(private)
second := typed("备注", 500)
if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" || second.Cause != nil || !errors.Is(first, private) {
t.Fatal(first, second)
}
if apierrors.UsernameInvalid().Message != "用户名格式不正确" {
t.Fatal("fixed compatibility")
}
cat := apierrors.Catalog()
cat.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool"
if typed("企业名称", 100).Message != first.Message {
t.Fatal("mutable catalog")
}
var wg sync.WaitGroup
for n := 0; n < 40; n++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
if n%2 == 0 {
if typed("企业名称", 100).Message != first.Message {
t.Error("isolation")
}
} else {
if typed("备注", 500).Message != second.Message {
t.Error("isolation")
}
}
}(n)
}
wg.Wait()
cfg := logx.Default()
cfg.Writer = io.Discard
logger, err := logx.New(cfg)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deps, err := servicecontext.New(context.Background(), logger)
if err != nil {
t.Fatal(err)
}
defer deps.Close()
app := omni.New()
defer app.Close()
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
switch c.Header("X-Fixture") {
case "company":
return typed("企业名称", 100).WithCause(private)
case "remark":
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "备注", "max": 500})
case "missing":
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称"})
case "type":
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": "100"})
case "fixed":
return apierrors.UsernameInvalid()
case "denied":
return apierrors.ResourceDenied("资源")
default:
return c.OK(map[string]string{"token": "ok"})
}
}
})
if err := handler.RegisterRoutes(app, deps); err != nil {
t.Fatal(err)
}
for _, path := range []string{"/token", "/raw"} {
for _, test := range []struct {
kind string
status int
want string
}{
{"company", 200, first.Message}, {"remark", 200, second.Message}, {"missing", 500, "internal server error"}, {"type", 500, "internal server error"}, {"fixed", 200, "用户名格式不正确"}, {"denied", 403, "资源不可用"}, {"", 200, `"token":"ok"`},
} {
method := "POST"
if path == "/raw" {
method = "GET"
}
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, nil)
r.Header.Set("X-Fixture", test.kind)
app.ServeHTTP(w, r)
if w.Code != test.status || !strings.Contains(w.Body.String(), test.want) || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "{label}") {
t.Fatal(test.kind, w.Code, w.Body.String())
}
if test.kind == "company" || test.kind == "remark" {
if !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), `"data":null`) {
t.Fatal(w.Body.String())
}
}
if path == "/raw" && test.kind == "" && strings.Contains(w.Body.String(), "code") {
t.Fatal("raw response wrapped")
}
}
}
}

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.10.0-preview"
const defaultFrameworkVersion = "v0.11.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.