fix: inherit App error catalogs on generated routes without local errors

This commit is contained in:
2026-09-24 15:42:03 +08:00
parent bca2ef92cc
commit a2a07a39ea
21 changed files with 570 additions and 54 deletions

View File

@@ -25,6 +25,8 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api
`v0.11.0-preview` 增加有序模板参数,生成带类型的错误函数和具体文档示例;示例见 [errorParams.api](../../examples/contracts/errorParams.api)。新生成代码需要同版或更新框架。
`v0.11.1-preview` 修复未声明错误的生成路由遮蔽 App 错误目录:错误可只在 Go 中注册一次,`.api` 不必重复维护;返回格式和路由参数继续按契约生成。
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
## 日志与对象存储按需装配
@@ -79,8 +81,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.11.0-preview
go-omni new --framework-version v0.11.0-preview --module example.com/billing ./billing
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview
go-omni new --framework-version v0.11.1-preview --module example.com/billing ./billing
cd billing
go mod tidy
go run ./cmd/server

View File

@@ -87,7 +87,7 @@ func TestErrorCatalogConsumer(t *testing.T) {
testCatalogConsumer(t, "errors.api", "errorCatalog")
}
func testCatalogConsumer(t *testing.T, apiFile, fixtureName string) {
func testCatalogConsumer(t *testing.T, apiFile, fixtureName string, extraFiles ...map[string]string) {
t.Helper()
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
@@ -118,12 +118,24 @@ func testCatalogConsumer(t *testing.T, apiFile, fixtureName string) {
}
generate(false)
fixture := readFile(t, filepath.Join("testdata", fixtureName, "consumer_test.go"))
writeFile(t, filepath.Join(dir, "consumer_test.go"), fixture)
files := map[string]string{"consumer_test.go": "consumer_test.go"}
for _, extra := range extraFiles {
for target, source := range extra {
files[target] = source
}
}
before := map[string]string{}
for target, source := range files {
fixture := readFile(t, filepath.Join("testdata", fixtureName, source))
writeFile(t, filepath.Join(dir, target), fixture)
before[target] = fixture
}
generate(false)
generate(true)
if readFile(t, filepath.Join(dir, "consumer_test.go")) != fixture {
t.Fatal("regeneration overwrote manual code")
for target, fixture := range before {
if readFile(t, filepath.Join(dir, target)) != fixture {
t.Fatalf("regeneration overwrote %s", target)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()

View File

@@ -0,0 +1,12 @@
package apigen
import "testing"
func TestErrorInheritanceConsumer(t *testing.T) {
testCatalogConsumer(t, "errorInheritance.api", "errorInheritance", map[string]string{
"internal/service/account/plain.go": "service.go",
"internal/service/account/raw.go": "emptyService.go",
"internal/service/account/cleared.go": "emptyService.go",
"internal/handler/account/boundedManual.go": "bounded.go",
})
}

View File

@@ -368,7 +368,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
}
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n"
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};encoder=response.MustWithErrors(catalog,encoder);if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};if len(catalog.Definitions)>0{encoder=response.MustWithErrors(catalog,encoder)};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext")
routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"

View File

@@ -0,0 +1,13 @@
package account
import (
"git.yuntuo.hk/k2safe/go-omni/httpx"
"io"
)
func (h *Handler) Bounded(c *httpx.Context) error {
if _, err := io.Copy(io.Discard, c.Request().Body); err != nil {
return err
}
return c.Text(200, "ACK")
}

View File

@@ -0,0 +1,129 @@
package consumer_test
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"example.com/errorCatalog/internal/handler"
"example.com/errorCatalog/internal/servicecontext"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestGoOwnedCatalogOnGeneratedRoutes(t *testing.T) {
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
"TEXT_TOO_LONG": {Status: 200, Code: 400003, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}},
"INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "系统错误"},
"BODY_TOO_LARGE": {Status: 413, Code: 413001, Message: "请求体超限"},
}}
lc := logx.Default()
lc.Writer = io.Discard
logger, err := logx.New(lc)
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()
format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always", RequestIDField: "-"}
app := omni.New(omni.WithErrors(catalog), omni.WithResponse(format))
defer app.Close()
resolve := func(c *httpx.Context) error {
switch c.Request().URL.Query().Get("kind") {
case "long":
return apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
case "unknown":
return apierrors.ByCode("NOT_REGISTERED")
default:
return apierrors.ByCode("USERNAME_INVALID")
}
}
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if c.Header("X-Early") == "true" {
return resolve(c)
}
return next(c)
}
})
app.GET("/ordinary", resolve)
if err := handler.RegisterRoutes(app, deps); err != nil {
t.Fatal(err)
}
// Explicit route policy stays independent of App errors and response fields.
local := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"USERNAME_INVALID": {Status: 409, Code: 409001, Message: "路由提示"}}}
localFormat := format
localFormat.MessageField = "detail"
app.HandleWithResponse("GET", "/independent", response.MustWithErrors(local, response.MustNew(localFormat)), resolve)
for _, early := range []string{"false", "true"} {
for _, test := range []struct {
kind string
status, code int
message string
}{
{"invalid", 400, 400002, "用户名格式不正确"}, {"long", 200, 400003, "企业名称不能超过100个字符"}, {"unknown", 500, 9999, "系统错误"},
} {
var ordinary string
for _, path := range []string{"/ordinary", "/plain", "/raw", "/cleared"} {
r := httptest.NewRequest("GET", path+"?kind="+test.kind, nil)
r.Header.Set("X-Early", early)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err, w.Body.String())
}
if w.Code != test.status || body["code"] != float64(test.code) || body["msg"] != test.message {
t.Fatal(path, early, w.Code, body)
}
if value, ok := body["data"]; !ok || value != nil {
t.Fatal("data:null missing", body)
}
if path == "/ordinary" {
ordinary = w.Body.String()
} else if w.Body.String() != ordinary {
t.Fatal("generated response differs", ordinary, w.Body.String())
}
}
}
r := httptest.NewRequest("GET", "/independent", nil)
r.Header.Set("X-Early", early)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
if w.Code != 409 || !strings.Contains(w.Body.String(), `"detail":"路由提示"`) || !strings.Contains(w.Body.String(), `"code":409001`) {
t.Fatal(w.Code, w.Body.String())
}
}
for _, path := range []string{"/plain", "/raw"} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", path+"?kind=success", nil))
if w.Code != 200 || !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatal(w.Code, w.Body.String())
}
if path == "/raw" && strings.Contains(w.Body.String(), "code") {
t.Fatal("raw success wrapped")
}
}
for _, size := range []int{8, 65} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("POST", "/bounded", strings.NewReader(strings.Repeat("x", size))))
if size == 8 {
if w.Code != 200 || w.Body.String() != "ACK" {
t.Fatal(w.Code, w.Body.String())
}
} else if w.Code != 413 || !strings.Contains(w.Body.String(), "请求体超限") {
t.Fatal(w.Code, w.Body.String())
}
}
}

View File

@@ -0,0 +1 @@
package account

View File

@@ -0,0 +1,29 @@
package account
import (
"context"
"example.com/errorCatalog/internal/types"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
func check(kind string) (*types.CheckResponse, error) {
switch kind {
case "success":
return &types.CheckResponse{OK: true}, nil
case "long":
return nil, apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": 100})
case "unknown":
return nil, apierrors.ByCode("NOT_REGISTERED")
default:
return nil, apierrors.ByCode("USERNAME_INVALID")
}
}
func (s *Service) Plain(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
return check(req.Kind)
}
func (s *Service) Raw(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
return check(req.Kind)
}
func (s *Service) Cleared(ctx context.Context, req *types.CheckRequest) (*types.CheckResponse, error) {
return check(req.Kind)
}

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.11.0-preview"
const defaultFrameworkVersion = "v0.11.1-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.