From a2a07a39ea045a08a99545b40e4de74b55b98422 Mon Sep 17 00:00:00 2001 From: k2safe Date: Thu, 24 Sep 2026 15:42:03 +0800 Subject: [PATCH] fix: inherit App error catalogs on generated routes without local errors --- API.md | 4 +- CHANGELOG.md | 6 + README.md | 22 +-- RELEASE.md | 46 +++---- app/errorInheritance_test.go | 126 +++++++++++++++++ cmd/go-omni/README.md | 6 +- cmd/go-omni/apigen/errorCatalog_test.go | 22 ++- cmd/go-omni/apigen/errorInheritance_test.go | 12 ++ cmd/go-omni/apigen/generate.go | 2 +- .../testdata/errorInheritance/bounded.go | 13 ++ .../errorInheritance/consumer_test.go | 129 ++++++++++++++++++ .../testdata/errorInheritance/emptyService.go | 1 + .../testdata/errorInheritance/service.go | 29 ++++ cmd/go-omni/scaffold/version.go | 2 +- errors/README.md | 24 +++- examples/contracts/errorInheritance.api | 41 ++++++ httpx/context.go | 3 +- performance/errorInheritanceAcceptance.md | 27 ++++ response/catalog_test.go | 19 +++ response/errors.go | 10 +- scripts/verifyErrorInheritanceRelease.py | 80 +++++++++++ 21 files changed, 570 insertions(+), 54 deletions(-) create mode 100644 app/errorInheritance_test.go create mode 100644 cmd/go-omni/apigen/errorInheritance_test.go create mode 100644 cmd/go-omni/apigen/testdata/errorInheritance/bounded.go create mode 100644 cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go create mode 100644 cmd/go-omni/apigen/testdata/errorInheritance/emptyService.go create mode 100644 cmd/go-omni/apigen/testdata/errorInheritance/service.go create mode 100644 examples/contracts/errorInheritance.api create mode 100644 performance/errorInheritanceAcceptance.md create mode 100644 scripts/verifyErrorInheritanceRelease.py diff --git a/API.md b/API.md index b6cb025..5217a9e 100644 --- a/API.md +++ b/API.md @@ -111,6 +111,8 @@ service user-api { ## 业务错误:声明、代码和文档共用一份定义 +**错误目录也可以只在 Go 中维护。** 从 `v0.11.1-preview` 起,生成路由没有声明错误列表时,继承 `omni.WithErrors(catalog)` 注册的 App 目录;`.api` 继续定义接口和返回格式,无需重复登记错误。全局中间件提前返回同样适用。显式引用 `.api` 错误时才使用路由独立目录,规则见 [目录继承](errors/README.md)。CLI 不执行运行时 Go 配置,因此静态文档不会自动枚举只在 Go 中定义的具体错误。 + 从 `v0.10.0-preview` 起,推荐用 `errorCatalog` 同时定义逻辑标识、对外业务码、提示和 HTTP 状态: ```text @@ -160,7 +162,7 @@ error ( 名称用 UpperCamelCase,HTTP 状态允许 200–599 中可携带响应体的最终状态(排除 204/205/304),旧声明的逻辑错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。`Catalog` 为生成函数保留名称。 `@server (errors: Unauthenticated)` 声明组级错误;路由 `@errors UserNotFound, EmailTaken` 追加到组级列表并去重。 -`@errors none` 清空继承的业务错误。未声明、重复声明或生成符号冲突会报告源码位置。 +`@errors none` 清空 `.api` 分组继承的错误列表,随后按未设置路由目录处理,继承 App 错误目录。未声明、重复声明或生成符号冲突会报告源码位置。 生成 `internal/apierrors/errorsGen.go`: diff --git a/CHANGELOG.md b/CHANGELOG.md index e66d309..0ddd06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.11.1-preview + +- 修复生成路由用空错误目录遮蔽 App 目录的问题;空 WithErrors 完全透传,避免 Context 与 Encoder 二次解析时丢失状态、业务码和中文提示。 +- 未声明错误列表的路由继承 App,支持错误只在 Go 中维护;显式路由目录保持独立,响应格式、RawJSON、body limit 和全局中间件提前返回保持正确策略。 +- 兼容已有 v0.11.0 生成代码;CLI 不再为空列表绑定目录。补充纯 Go 错误目录的实际生成消费者与回归验收。 + ## v0.11.0-preview - 错误目录增加有序 params 和命名提示模板,生成带类型的错误函数;固定提示保持兼容,业务不再自行拼接文案。 diff --git a/README.md b/README.md index 3a7967d..adda880 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始即可接入。** 当前版本为 **`v0.11.0-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 +**从这份 README 开始即可接入。** 当前版本为 **`v0.11.1-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。 @@ -26,7 +26,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE=git.yuntuo.hk -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -38,7 +38,7 @@ go-omni --help 在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。 ```sh -go-omni api new --framework-version v0.11.0-preview \ +go-omni api new --framework-version v0.11.1-preview \ --module example.com/hello ./hello cd hello go mod tidy @@ -126,7 +126,7 @@ service catalog-api { ```sh go-omni api validate --api catalog.api -go-omni new --api catalog.api --framework-version v0.11.0-preview \ +go-omni new --api catalog.api --framework-version v0.11.1-preview \ --module example.com/catalog ./catalog cd catalog go mod tidy @@ -167,21 +167,21 @@ curl -i http://127.0.0.1:8080/api/v1/items/missing 需要 MySQL、文件存储或文件日志时,**首次生成**可以使用: ```sh -go-omni new --api catalog.api --framework-version v0.11.0-preview \ +go-omni new --api catalog.api --framework-version v0.11.1-preview \ --module example.com/catalogFull --database mysql --storage local \ --log-output file --log-format json ./catalogFull ``` 该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整装配例子见 [userService](examples/userService/README.md)。 -不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.11.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。 +不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.11.1-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。 ## 已有项目:按模块接入 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.11.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。 @@ -309,7 +309,9 @@ auth UserAuth ( 业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。 -API 项目推荐在公共 `.api` 中声明目录,其他契约通过 `import` 引用: +错误目录支持两种维护方式,按项目选择一种:**Go 中定义并用 `omni.WithErrors` 注册一次**,或者在公共 `.api` 中声明并生成调用函数。前者不要求 `.api` 重复声明目录或逐路由列出错误;生成路由默认继承 App 目录。 + +采用 `.api` 管理时,其他契约可通过 `import` 引用: ```text errorCatalog CommonErrors ( @@ -358,7 +360,7 @@ app := omni.New( // return omnierrors.ByCode("USERNAME_INVALID").WithCause(err) ``` -目录也可放入 `config.Config.Errors`,由 JSON/YAML 加载;生成项目的路径为 `server.errors`,但契约生成路由以 `.api` 绑定的目录为准。不同 App 独立持有配置,单路由也可通过 `response.WithErrors` 绑定专用目录与自定义 Encoder。直接输出文本、XML、下载或已提交的响应继续保留原协议。 +目录也可放入 `config.Config.Errors`,由 JSON/YAML 加载;生成项目的路径为 `server.errors`,生成路由只有显式引用错误定义时才使用 `.api` 独立目录,未声明则继承 App。不同 App 独立持有配置,单路由也可通过 `response.WithErrors` 绑定专用目录与自定义 Encoder。直接输出文本、XML、下载或已提交的响应继续保留原协议。 访问日志保留实际 HTTP 状态,并增加失败标记、逻辑错误标识和目录业务码;指标、链路及框架标准中间件/幂等重放也保留失败语义。未知标识使用系统错误兜底,内部 cause 不写入默认公开响应。旧 `error (...)`、`New` / `BadRequest` / `Codes` / `Messages` 继续支持;新目录与旧映射冲突会报错,避免静默覆盖。 @@ -447,7 +449,7 @@ go test ./... `Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Service、Repository、中间件实现、main、配置和依赖装配属于项目,重复生成保留手写内容;契约改变后仍需自行调整业务签名并通过编译。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 可运行 `generate --check` 检查是否同步。 -升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.11.0-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.11.1-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index 6a67da7..a819d04 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,44 +1,40 @@ -# v0.11.0-preview +# v0.11.1-preview -错误目录支持提示模板和有序类型参数。业务调用 `apierrors.TextTooLong("企业名称", 100)`,错误目录统一定义业务码、HTTP 状态与文案,运行时入口和生成函数共用解析逻辑。 +修复生成路由使用空错误目录遮蔽 `omni.WithErrors(catalog)` 的问题。错误现在可以只在 Go 中注册一次,`.api` 无需重复定义错误目录或逐条列出错误。 -## 升级与接入 +## 行为 + +- 未声明路由错误列表:继承 App 目录,包括带参数提示及项目自定义 INTERNAL_ERROR 兜底。 +- 显式引用路由错误:继续使用路由独立目录,不与 App 合并。 +- `@errors none` 清空 `.api` 分组错误列表,随后继承 App。 +- 错误继承不覆盖 `.api` 的返回格式、RawJSON、手写绑定或请求体限额;全局中间件提前返回、标准桥接与克隆保持一致。 +- 空 `response.WithErrors` / `MustWithErrors` 返回原 Encoder,既不覆盖 Context 的目录,也不在编码阶段按空目录重新解析错误。 + +## 升级 ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.11.0-preview -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview go-omni api generate --dry-run go-omni api generate go test ./... ``` -先升级依赖,再使用同版 CLI。新生成代码最低要求本版;已有固定错误、v0.10.0 目录、无参数函数和旧生成代码继续支持。模板错误一旦声明参数,修改其顺序/类型会改变生成函数签名,项目需同步修改调用方。手写文件仍受重生成保护。 +运行时修复也适用于旧 v0.11.0 生成代码,因此升级运行时后即可解除空目录遮蔽。同步升级 CLI 重新生成时不再创建空目录包装;已生成业务文件继续受保护。代码生成最低运行时仍为 v0.11.0-preview。 -从 [README](README.md) 接入,完整规则见 [错误目录与模板参数](errors/README.md),可直接生成的契约见 [errorParams.api](examples/contracts/errorParams.api)。 +接入见 [README](README.md)、[错误目录继承](errors/README.md) 和 [只在 Go 注册错误的契约示例](examples/contracts/errorInheritance.api)。静态 OpenAPI 不执行 Go 配置,保留契约响应结构,不自动列举运行时目录内容。 -## 能力与边界 +## 验收 -- 有序 `params` 决定函数参数顺序;支持 string/bool/int/int32/int64/float64。目录初始化检查模板、占位符、参数类型与文档 example。 -- `ByCode(key).WithParams(values)` 复制标量参数;生成函数立即解析完整 Message,非 HTTP 使用显式 Registry,不引入全局注册器。 -- HTTP 200 或逐项覆盖的状态、返回体模板、data:null、RawJSON 及自定义 Encoder 保持项目配置。内部 cause 不自动进入公开提示。 -- 运行时缺参、多参、类型/范围错误转系统兜底,日志包含 ParamError 诊断;不发送未替换的模板或原始参数值。 -- 路由、中间件和 Context 克隆保留参数。幂等重放保存已解析公开快照,目录更新不覆盖历史响应;不保存原始参数/cause,大整数码不经浮点转换。 -- OpenAPI 保留模板参数定义,响应 examples 显示具体提示。文档 example 不作为运行时缺参默认值。 -- 省略 params 的旧固定提示保留字面花括号;空 params 数组明确拒绝。模板错误同标识旧 messages 映射需移除。 -- 本轮没有修改 eTax/ePay 业务代码;业务侧需升级版本并声明自己的通用校验错误。 - -## 验证入口 +复现用例在修复前:普通路由通过,生成/RawJSON 路由和提前返回中间件共 12 项失败,均变成 500/英文系统提示。修复后同一批用例通过。 ```sh GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./... go vet ./... go build ./... -# 使用已有 redis:7-alpine 镜像启动本机有资源限制的临时容器: -python3 scripts/verifyErrorParamsRedis.py -# 发布后的远程固定版本消费者: -python3 scripts/verifyErrorParamsRelease.py --version v0.11.0-preview -python3 scripts/verifyErrorCatalogRelease.py --version v0.11.0-preview --report performance/errorParamsCatalogRemote.json -python3 scripts/verifyCompatibilityRelease.py --version v0.11.0-preview --report performance/errorParamsCompatibilityRemote.json +python3 scripts/verifyErrorInheritanceRelease.py --version v0.11.1-preview +python3 scripts/verifyErrorParamsRelease.py --version v0.11.1-preview --report performance/errorInheritanceParamsRemote.json +python3 scripts/verifyCompatibilityRelease.py --version v0.11.1-preview --report performance/errorInheritanceCompatibilityRemote.json ``` -验收结果见 [提示模板验收](performance/errorParamsAcceptance.md)。历史 [v0.10.0 错误目录验收](performance/errorCatalogAcceptance.md) 保留。 +结果见 [目录继承验收](performance/errorInheritanceAcceptance.md)。本轮未修改 eTax/ePay 业务代码,没有使用业务服务器。 diff --git a/app/errorInheritance_test.go b/app/errorInheritance_test.go new file mode 100644 index 0000000..fdda751 --- /dev/null +++ b/app/errorInheritance_test.go @@ -0,0 +1,126 @@ +package app_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + 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/response" +) + +func TestRouteInheritsAppErrorCatalog(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: "系统错误"}, + }} + format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"} + app := omni.New(omni.WithErrors(catalog), omni.WithResponse(format)) + defer app.Close() + getError := func(c *httpx.Context) error { + key := c.Header("X-Error") + if key == "TEXT_TOO_LONG" { + return apierrors.ByCode(key).WithParams(map[string]any{"label": "企业名称", "max": 100}) + } + return apierrors.ByCode(key) + } + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { + if c.Header("X-Early") == "true" { + return getError(c) + } + return next(c) + } + }) + bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) + }) + app.GET("/plain", getError) + // This is exactly the wrapper emitted by v0.11.0, not just the new generator. + legacy := response.MustWithErrors(apierrors.Catalog{}, response.MustNew(format)) + app.HandleWithResponse("GET", "/generated", legacy, getError, bridge) + app.HandleWithResponse("GET", "/raw", response.RawJSON(legacy), getError, bridge) + for _, path := range []string{"/plain", "/generated", "/raw"} { + for _, early := range []string{"false", "true"} { + for _, test := range []struct { + key string + status, code int + message string + }{ + {"USERNAME_INVALID", 400, 400002, "用户名格式不正确"}, + {"TEXT_TOO_LONG", 200, 400003, "企业名称不能超过100个字符"}, + {"MISSING", 500, 9999, "系统错误"}, + } { + t.Run(path+"/"+early+"/"+test.key, func(t *testing.T) { + r := httptest.NewRequest("GET", path, nil) + r.Header.Set("X-Early", early) + r.Header.Set("X-Error", test.key) + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + var value map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil { + t.Fatal(err, w.Body.String()) + } + if w.Code != test.status || value["code"] != float64(test.code) || value["msg"] != test.message { + t.Fatal(w.Code, value) + } + if data, ok := value["data"]; !ok || data != nil { + t.Fatal("data:null lost", value) + } + }) + } + } + } +} + +func TestExplicitRouteErrorCatalogRemainsIndependent(t *testing.T) { + format := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"} + global := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{ + "INVALID": {Code: 400002, Message: "全局提示"}, "APP_ONLY": {Code: 400003, Message: "全局独有"}, + "INTERNAL_ERROR": {Status: 500, Code: 9999, Message: "全局系统错误"}, + }} + local := apierrors.Catalog{DefaultStatus: 409, Definitions: map[string]apierrors.Definition{ + "INVALID": {Code: 409001, Message: "路由提示"}, "INTERNAL_ERROR": {Status: 500, Code: 9998, Message: "路由系统错误"}, + }} + app := omni.New(omni.WithErrors(global), omni.WithResponse(format)) + defer app.Close() + getError := func(c *httpx.Context) error { return apierrors.ByCode(c.Header("X-Error")) } + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { + if c.Header("X-Early") == "true" { + return getError(c) + } + return next(c) + } + }) + routeFormat := format + routeFormat.MessageField = "detail" + localEncoder := response.MustWithErrors(local, response.MustNew(routeFormat)) + app.HandleWithResponse("GET", "/", response.RawJSON(localEncoder), getError) + for _, early := range []string{"false", "true"} { + for _, test := range []struct { + key string + status, code int + message string + }{ + {"INVALID", 409, 409001, "路由提示"}, {"APP_ONLY", 500, 9998, "路由系统错误"}, + } { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-Early", early) + r.Header.Set("X-Error", test.key) + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + var value map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil { + t.Fatal(err) + } + if w.Code != test.status || value["code"] != float64(test.code) || value["detail"] != test.message || value["msg"] != nil { + t.Fatal(w.Code, value) + } + } + } +} diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 3a57ec3..3a91cea 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -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 diff --git a/cmd/go-omni/apigen/errorCatalog_test.go b/cmd/go-omni/apigen/errorCatalog_test.go index f550b5f..f9cdbd5 100644 --- a/cmd/go-omni/apigen/errorCatalog_test.go +++ b/cmd/go-omni/apigen/errorCatalog_test.go @@ -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() diff --git a/cmd/go-omni/apigen/errorInheritance_test.go b/cmd/go-omni/apigen/errorInheritance_test.go new file mode 100644 index 0000000..a1d8d91 --- /dev/null +++ b/cmd/go-omni/apigen/errorInheritance_test.go @@ -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", + }) +} diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 1929f2d..5a7b39e 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -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" diff --git a/cmd/go-omni/apigen/testdata/errorInheritance/bounded.go b/cmd/go-omni/apigen/testdata/errorInheritance/bounded.go new file mode 100644 index 0000000..6d2ba6f --- /dev/null +++ b/cmd/go-omni/apigen/testdata/errorInheritance/bounded.go @@ -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") +} diff --git a/cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go b/cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go new file mode 100644 index 0000000..d056a2c --- /dev/null +++ b/cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go @@ -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()) + } + } +} diff --git a/cmd/go-omni/apigen/testdata/errorInheritance/emptyService.go b/cmd/go-omni/apigen/testdata/errorInheritance/emptyService.go new file mode 100644 index 0000000..0bc7635 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/errorInheritance/emptyService.go @@ -0,0 +1 @@ +package account diff --git a/cmd/go-omni/apigen/testdata/errorInheritance/service.go b/cmd/go-omni/apigen/testdata/errorInheritance/service.go new file mode 100644 index 0000000..79da7c1 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/errorInheritance/service.go @@ -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) +} diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 50831d4..78d0ae4 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -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. diff --git a/errors/README.md b/errors/README.md index 2853390..6422d35 100644 --- a/errors/README.md +++ b/errors/README.md @@ -43,6 +43,22 @@ Service 返回 `omnierrors.ByCode("USERNAME_INVALID")`,或 `.WithCause(err)` 原有 `New(status, key, message)` 等明确错误也能被同标识目录条目覆盖;未在目录中的明确错误沿用原状态与提示。未配置目录时,原有行为保持。Go 目录允许 `Definition.Code == nil` 以继续使用旧 Codes/errorCode 映射;新 `.api errorCatalog` 要求显式 code。 +## Go 维护错误目录,生成路由继承 App(v0.11.1-preview) + +错误可以只在 Go 中定义,并在 `omni.New(omni.WithErrors(catalog), ...)` 注册一次。`.api` 只写接口、请求/响应 DTO 和响应格式,**无需再声明 errorCatalog,也无需为每条路由列出错误**。业务直接返回 `ByCode(key)` 或 `ByCode(key).WithParams(...)`。 + +选择规则: + +- 生成路由没有有效的 `@errors` / `@server errors` 列表时,继承 App 目录,包括 App 配置的 INTERNAL_ERROR 中文兜底。 +- 路由显式引用了错误定义时,使用该路由自己的目录;不与 App 目录合并,保持独立语义。 +- `@errors none` 清空 `.api` 分组继承的错误列表;清空后按没有路由目录处理,继承 App,而不是禁用错误处理。 +- 错误目录与响应格式独立选择。继承 App 错误不覆盖 `.api` 的响应模板、RawJSON、绑定方式和请求体限额;响应 `codeType` 需能表示目录中的业务码。 +- App 全局中间件提前返回错误、普通 Handler 和 Context 克隆遵循相同规则。 + +`response.WithErrors` / `MustWithErrors` 收到空目录时返回原 Encoder,不建立空 Registry,也不在编码时再次解析原始 error。已有 v0.11.0 生成代码中的空包装因此也能通过升级运行时修复;同步升级 CLI 重新生成则不再产生空包装。 + +可生成的示例见 [errorInheritance.api](../examples/contracts/errorInheritance.api)。Go 目录是在运行时注册的,CLI 不执行项目 Go 代码;静态 OpenAPI 保留 `.api` 中的响应结构,不会自动枚举 Go 目录的具体错误码、提示及状态。这不影响运行时接入,也不要求重复维护目录。 + ## 文件配置 下面是生成项目的 `etc/config.yaml` 结构;直接 `config.LoadFile` 加载框架配置时去掉外面的 `server`。手写 main 需显式将加载结果传入 `WithConfig`。 @@ -106,9 +122,9 @@ service account-api { - 自动绑定到生成路由的目录:只包含该路由通过 `@errors` / `@server errors` 声明的错误,在 App 全局中间件之前生效,支持认证提前返回和缓冲克隆。 - OpenAPI 的错误响应与示例。同一 HTTP 200 下用 `anyOf` 同时描述成功 DTO 和错误体;多个错误共用数字码时保持多个示例;raw 成功体与失败公共体也分开描述。 -业务直接 `return TokenResponse{}, apierrors.UsernameInvalid()`。也可以返回 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`;使用标识引用时,当前 App/路由目录必须包含该错误。不要把“函数已生成”视作“所有路由都已声明这个错误”:新增实际错误后仍应补相应 `@errors`,保持文档准确。 +业务直接 `return TokenResponse{}, apierrors.UsernameInvalid()`。也可以返回 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`;使用标识引用时,当前生效的目录必须包含该错误。采用 `.api` 管理路由独立目录时,新增错误需补充该路由的 `@errors`;采用 Go 注册 App 目录时,无需在 `.api` 重复声明。 -生成路由的目录和响应由 `.api` 管理;YAML 或 App 默认策略不覆盖它。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 并选择兼容的 `WithResponse`。新项目和重复生成都会自动绑定路由目录,无需手动修改 main;已有手写文件保持不动。 +显式声明了错误列表的生成路由使用 `.api` 目录,未声明的路由继承 App;响应格式始终按各自配置选择。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 复用目录并选择兼容的 `WithResponse`。已有手写文件保持不动。 ## 提示模板参数(v0.11.0-preview) @@ -185,8 +201,8 @@ Encoder 的 `response.Result` 包含逻辑 `Code`、对外 `BusinessCode`、公 ## 升级 ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.11.0-preview -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.11.1-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.1-preview go-omni api generate --dry-run go-omni api generate go test ./... diff --git a/examples/contracts/errorInheritance.api b/examples/contracts/errorInheritance.api new file mode 100644 index 0000000..2bff78b --- /dev/null +++ b/examples/contracts/errorInheritance.api @@ -0,0 +1,41 @@ +syntax = "v1" + +// Errors are registered once in Go with omni.WithErrors. +response CommonResponse ( + codeType: int32 + successCode: 0 + errorCode: 9999 + body: {"code": "${code}", "msg": "${message}", "data": "${data}"} + dataMode: always +) + +type CheckRequest { + Kind string `form:"kind,optional"` +} +type CheckResponse { + OK bool `json:"ok"` +} + +@server ( + group: account + response: CommonResponse +) +service inherited-errors-api { + @handler plain + get /plain (CheckRequest) returns (CheckResponse) + + @response raw + @handler raw + get /raw (CheckRequest) returns (CheckResponse) + + @errors none + @handler cleared + get /cleared (CheckRequest) returns (CheckResponse) + + @handlerMode manual + @body (mode: raw + maxBytes: 64) + @produces "text/plain" + @handler bounded + post /bounded +} diff --git a/httpx/context.go b/httpx/context.go index e7638ae..bd82fe1 100644 --- a/httpx/context.go +++ b/httpx/context.go @@ -299,7 +299,8 @@ func (c *Context) WriteError(err error) { } // SetErrorRegistry sets an application fallback. Route encoders can supply their -// own registry; clones retain both policies without any process-global lookup. +// own nonempty registry; an encoder without a registry inherits this fallback. +// Clones retain both policies without any process-global lookup. func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r } func (c *Context) ResolveError(err error) *apierrors.APIError { registry := c.errorRegistry diff --git a/performance/errorInheritanceAcceptance.md b/performance/errorInheritanceAcceptance.md new file mode 100644 index 0000000..9f4088a --- /dev/null +++ b/performance/errorInheritanceAcceptance.md @@ -0,0 +1,27 @@ +# v0.11.1-preview App 错误目录继承验收 + +## 问题与修复 + +在 Go 中用 `omni.WithErrors` 注册目录后,v0.11.0 生成路由无条件创建空目录 Encoder。Context 选择了该空目录,编码阶段又按空目录解析原始错误,造成已配置错误变成 500/英文系统提示。 + +修复让空 `WithErrors` 保持 Encoder 不变,生成器仅为非空路由目录绑定策略。Context 原有的 App fallback 因而能够生效;错误目录与响应格式仍独立选择。兼容旧生成代码中的空包装,非空路由目录保持独立、不与 App 合并。 + +## 复现与回归 + +- 修复前,`TestRouteInheritsAppErrorCatalog` 的普通路由通过;生成/RawJSON 路由在 Handler 与提前返回中间件两种入口、固定/模板/未知错误三种情况,共 12 项失败,均输出 500 和英文系统提示。 +- 修复后该用例全部通过;包括 HTTP 400/数字 400002/中文提示/data:null,HTTP 200 模板提示,未知错误使用项目配置的中文 INTERNAL_ERROR。 +- 独立路由用例验证相同错误标识使用路由自己的 409/业务码/提示,App 独有标识不渗入;路由的 detail 字段保持独立。 +- EmptyCatalog 编码用例验证完全透传已解析结果,保留原始 error 时也不会二次查空表;非法空目录配置仍拒绝。 +- 实际生成项目的 `.api` 没有任何 errorCatalog/error 定义。生成 Handler 调用手写 Service;普通路由、生成路由和全局中间件返回体完全一致,含模板参数、未知兜底和 data:null。 +- 生成消费者还覆盖 RawJSON 成功不包装、@errors none 继承 App、手写原文回执、路由 body limit 及超限中文错误、手写 Service 的重生成保护。 + +## 本地验证 + +环境:go1.27.1 darwin/arm64;日期:2026-09-24。 + +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过。 +- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 通过。 + +本轮仅本地测试及独立生成消费者,没有修改 eTax/ePay 业务代码,没有访问业务服务器;未重跑外部 Redis/MySQL、Linux soak 或完整业务迁移。 + +远程固定版本验收在发布后追加,标签保持不可变。 diff --git a/response/catalog_test.go b/response/catalog_test.go index 1492761..3a0d8fb 100644 --- a/response/catalog_test.go +++ b/response/catalog_test.go @@ -80,3 +80,22 @@ func TestCatalogWrapperPreservesScope(t *testing.T) { } } } + +func TestEmptyCatalogIsTransparent(t *testing.T) { + formatter := response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}) + for _, inner := range []response.Encoder{formatter, response.RawJSON(formatter)} { + wrapped, err := response.WithErrors(apierrors.Catalog{}, inner) + if err != nil || wrapped != inner { + t.Fatal("empty catalog introduced a route policy", err) + } + // Context resolved this through the App. Original Err must not cause a + // second lookup to replace its status, public code or rendered message. + payload, err := wrapped.Encode(response.Result{Status: 400, Code: "INVALID", BusinessCode: int64(400002), Message: "项目提示", Failed: true, Err: apierrors.ByCode("INVALID")}) + if err != nil || payload.Status != 400 || !strings.Contains(string(payload.Body), "项目提示") { + t.Fatal(payload, err) + } + } + if _, err := response.WithErrors(apierrors.Catalog{DefaultStatus: 204}, formatter); err == nil { + t.Fatal("invalid empty catalog bypassed validation") + } +} diff --git a/response/errors.go b/response/errors.go index 4045dd4..6aa1eb8 100644 --- a/response/errors.go +++ b/response/errors.go @@ -26,7 +26,9 @@ func (e *errorEncoder) Encode(r Result) (Payload, error) { // WithErrors binds a catalog to an encoder without imposing a response shape. // Built-in templates are checked for incompatible code types and conflicting -// legacy mappings. Custom encoders remain responsible for their wire contract. +// legacy mappings. An empty catalog leaves next unchanged, so Context can +// inherit its App catalog without a second, empty lookup during encoding. +// Custom encoders remain responsible for their wire contract. func WithErrors(catalog apierrors.Catalog, next Encoder) (Encoder, error) { registry, err := apierrors.NewRegistry(catalog) if err != nil { @@ -35,10 +37,10 @@ func WithErrors(catalog apierrors.Catalog, next Encoder) (Encoder, error) { if next == nil { next = Default() } + if len(catalog.Definitions) == 0 { + return next, nil + } if provider, ok := next.(ErrorRegistryProvider); ok && provider.ErrorRegistry() != nil { - if len(catalog.Definitions) == 0 { - return next, nil - } if reflect.DeepEqual(registry.Catalog(), provider.ErrorRegistry().Catalog()) { return next, nil } diff --git a/scripts/verifyErrorInheritanceRelease.py b/scripts/verifyErrorInheritanceRelease.py new file mode 100644 index 0000000..17a4a70 --- /dev/null +++ b/scripts/verifyErrorInheritanceRelease.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Install the released CLI in a fresh cache, then test its generated consumer. + +The fixture tests Go-owned App catalogs on generated routes, early middleware, raw responses, +body limits, independent route policies and regeneration protection. Only local temporary resources +are used; the framework and CLI are fetched from the specified remote version. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +MODULE = "git.yuntuo.hk/k2safe/go-omni" + + +def run(args, cwd, env, timeout=300): + result = subprocess.run([str(arg) for arg in args], cwd=cwd, env=env, + capture_output=True, text=True, timeout=timeout) + output = re.sub(r"https://[^/@\s]+:[^/@\s]+@", "https://", result.stdout + result.stderr) + if result.returncode: + raise RuntimeError(f"{args[0]} failed ({result.returncode}):\n{output}") + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--report", type=Path, default=ROOT / "performance/errorInheritanceRemote.json") + args = parser.parse_args() + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version): + parser.error("a fixed release version is required") + env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")} + env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk") + start = time.monotonic() + with tempfile.TemporaryDirectory(prefix="goOmniErrorInheritanceRelease-") as directory: + temp = Path(directory) + (temp / "bin").mkdir() + env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache")) + print(f"Installing CLI {args.version} in an empty module cache", flush=True) + run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env) + cli = temp / "bin/go-omni" + run([cli, "api", "validate", "--api", ROOT / "examples/contracts/errorInheritance.api"], temp, env) + env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) + print("Testing remote App catalog inheritance on generated handlers and early middleware", flush=True) + output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s", + "./cmd/go-omni/apigen", "-run", "^TestErrorInheritanceConsumer$"], ROOT, env) + events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] + passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e] + assert passed == ["TestErrorInheritanceConsumer"], output + evidence = "".join(e.get("Output", "") for e in events) + checksum = re.search(r"checksum (h1:\S+); no local replace", evidence) + assert checksum, "consumer did not verify its remote dependency" + report = { + "passed": True, "frameworkVersion": args.version, "frameworkSum": checksum.group(1), + "freshModuleCache": True, "noLocalReplace": True, + "durationSeconds": round(time.monotonic() - start, 2), + "checks": ["remoteCLIInstall", "noAPICatalogRequired", "ordinaryAndGeneratedResponseEquality", + "typedMessageParameters", "configuredUnknownFallback", "independentRouteCatalog", + "rawSuccess", "dataNull", "earlyMiddlewareInheritance", "routeBodyLimit", + "handwrittenServicesPreserved", "generatedConsumerRaceTests", "fixedRemoteModuleChecksum"], + "sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in + ("scripts/verifyErrorInheritanceRelease.py", "cmd/go-omni/apigen/errorInheritance_test.go", "cmd/go-omni/apigen/errorCatalog_test.go", + "examples/contracts/errorInheritance.api", + "cmd/go-omni/apigen/testdata/errorInheritance/consumer_test.go", + "cmd/go-omni/apigen/testdata/errorInheritance/service.go", + "cmd/go-omni/apigen/testdata/errorInheritance/emptyService.go", + "cmd/go-omni/apigen/testdata/errorInheritance/bounded.go")}, + } + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(f"PASS remote App error catalog inheritance {args.version}; temporary files removed", flush=True) + + +if __name__ == "__main__": + main()