From 6e3f025aad4e498217ef4e7346725fc7d892940c Mon Sep 17 00:00:00 2001 From: k2safe Date: Thu, 24 Sep 2026 15:05:08 +0800 Subject: [PATCH] feat: add typed error message templates and stable replay snapshots --- API.md | 15 + CHANGELOG.md | 9 + README.md | 25 +- RELEASE.md | 54 ++-- app/errorParams_test.go | 186 ++++++++++++ cmd/go-omni/README.md | 6 +- cmd/go-omni/apigen/contractMetadata.go | 8 +- cmd/go-omni/apigen/declarations.go | 17 +- cmd/go-omni/apigen/errorCatalog.go | 13 +- cmd/go-omni/apigen/errorCatalog_test.go | 9 +- cmd/go-omni/apigen/errorParams_test.go | 86 ++++++ cmd/go-omni/apigen/generate.go | 22 +- .../testdata/errorParams/consumer_test.go | 149 ++++++++++ cmd/go-omni/scaffold/version.go | 2 +- config/errorParams_test.go | 44 +++ errors/README.md | 68 ++++- errors/catalog.go | 59 +++- errors/errors.go | 5 +- errors/params.go | 272 ++++++++++++++++++ errors/params_test.go | 160 +++++++++++ errors/snapshot.go | 71 +++++ examples/contracts/errorParams.api | 48 ++++ httpx/context.go | 7 +- middleware/idempotency.go | 22 +- middleware/middleware.go | 5 + middleware/redis/errorParams_test.go | 81 ++++++ middleware/sharedIdempotency.go | 32 ++- performance/errorParamsAcceptance.md | 27 ++ performance/errorParamsRedis.json | 25 ++ response/errors.go | 3 + scripts/verifyErrorParamsRedis.py | 59 ++++ scripts/verifyErrorParamsRelease.py | 80 ++++++ 32 files changed, 1570 insertions(+), 99 deletions(-) create mode 100644 app/errorParams_test.go create mode 100644 cmd/go-omni/apigen/errorParams_test.go create mode 100644 cmd/go-omni/apigen/testdata/errorParams/consumer_test.go create mode 100644 config/errorParams_test.go create mode 100644 errors/params.go create mode 100644 errors/params_test.go create mode 100644 errors/snapshot.go create mode 100644 examples/contracts/errorParams.api create mode 100644 middleware/redis/errorParams_test.go create mode 100644 performance/errorParamsAcceptance.md create mode 100644 performance/errorParamsRedis.json create mode 100644 scripts/verifyErrorParamsRedis.py create mode 100644 scripts/verifyErrorParamsRelease.py diff --git a/API.md b/API.md index 7769c1f..b6cb025 100644 --- a/API.md +++ b/API.md @@ -130,6 +130,21 @@ errorCatalog CommonErrors ( 同一 HTTP 200 下,OpenAPI 用 `anyOf` 保留成功 DTO 和错误体,日志与指标仍识别业务失败。完整语法、路由作用域与手写项目配置见 [统一错误目录](errors/README.md),可运行契约见 [errors.api](examples/contracts/errors.api)。 +从 `v0.11.0-preview` 起,目录条目支持 `params` 有序列表与 `{name}` 提示模板: + +```json +"TextTooLong": { + "key": "TEXT_TOO_LONG", "code": 400002, + "message": "{label}不能超过{max}个字符", + "params": [ + {"name": "label", "type": "string", "example": "企业名称"}, + {"name": "max", "type": "int", "example": 100} + ] +} +``` + +生成 `apierrors.TextTooLong(label string, max int)`,返回时 Message 已填好;固定提示保持无参数函数。声明顺序决定参数顺序。模板校验、运行时缺参/类型错误、转义及非 HTTP 使用规则见 [提示模板参数](errors/README.md)。OpenAPI 的错误目录保留参数定义,响应示例使用具体文案。 + 原有声明继续支持: ```go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c662c1..e66d309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.11.0-preview + +- 错误目录增加有序 params 和命名提示模板,生成带类型的错误函数;固定提示保持兼容,业务不再自行拼接文案。 +- 新增 WithParams、参数校验与共用解析器;复制请求参数、保留错误链,重复解析和缓冲链路保留填充结果。非法调用转系统兜底,访问日志提供不含参数值的诊断。 +- OpenAPI 错误目录同步参数类型/示例,响应示例由运行时解析器生成;JSON/YAML 配置同样支持模板。 +- 本地/共享幂等重放保存已解析公开错误快照,不保存原始参数或 cause;Redis Lua 重写记录不损失 int64 业务码精度。 +- 模板编译拒绝未声明/未使用参数、非法类型及示例;模板错误不能再维护同标识的旧 messages 映射。 +- CLI 新生成代码最低要求 v0.11.0-preview;已生成的固定错误函数和 v0.10.0 调用继续支持。 + ## v0.10.0-preview - 新增 App/路由独立错误目录:`omni.WithErrors`、`errors.Catalog`、`ByCode` 与不可变 Registry,业务只选逻辑标识;对外码和提示由项目配置。 diff --git a/README.md b/README.md index 3ac3764..3a7967d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始即可接入。** 当前版本为 **`v0.10.0-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。 +**从这份 README 开始即可接入。** 当前版本为 **`v0.11.0-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.10.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.11.0-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.10.0-preview \ +go-omni api new --framework-version v0.11.0-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.10.0-preview \ +go-omni new --api catalog.api --framework-version v0.11.0-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.10.0-preview \ +go-omni new --api catalog.api --framework-version v0.11.0-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.10.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。 +不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.11.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。 ## 已有项目:按模块接入 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.11.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。 @@ -362,6 +362,15 @@ app := omni.New( 访问日志保留实际 HTTP 状态,并增加失败标记、逻辑错误标识和目录业务码;指标、链路及框架标准中间件/幂等重放也保留失败语义。未知标识使用系统错误兜底,内部 cause 不写入默认公开响应。旧 `error (...)`、`New` / `BadRequest` / `Codes` / `Messages` 继续支持;新目录与旧映射冲突会报错,避免静默覆盖。 +动态提示也可以集中配置,在目录中声明 `message: "{label}不能超过{max}个字符"` 和有序 `params`(label/string、max/int),生成带类型的调用: + +```go +return apierrors.TextTooLong("企业名称", 100) +// 最终提示:企业名称不能超过100个字符 +``` + +运行时支持 `omnierrors.ByCode("TEXT_TOO_LONG").WithParams(...)`,与生成函数共用解析器。固定错误继续无参数调用;缺参或类型错误使用系统兜底。模板、参数类型及具体响应示例同步写入 OpenAPI。完整示例见 [errorParams.api](examples/contracts/errorParams.api)。 + 更详细的配置、作用域和升级步骤见 [统一错误目录](errors/README.md)。 ### 数据库、存储及其他模块 @@ -438,7 +447,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.10.0-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.11.0-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index 4b0c9da..6a67da7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,38 +1,31 @@ -# v0.10.0-preview +# v0.11.0-preview -统一错误目录:由项目集中定义逻辑标识、对外业务码、公开提示和 HTTP 状态,业务只选择错误。支持 HTTP 200 的业务失败,并贯通响应、错误函数生成、日志与 OpenAPI。 +错误目录支持提示模板和有序类型参数。业务调用 `apierrors.TextTooLong("企业名称", 100)`,错误目录统一定义业务码、HTTP 状态与文案,运行时入口和生成函数共用解析逻辑。 -## 升级 +## 升级与接入 ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview -# 使用 .api 的项目再执行: +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-omni api generate --dry-run go-omni api generate go test ./... ``` -先升级项目依赖,再使用同版 CLI。新生成代码最低运行时为本版;旧的 APIError/New/BadRequest、Codes/Messages、error 声明及旧生成代码继续支持。API 错误包增加 `Catalog()`,该名称成为保留符号。项目的手写 main、Service 和 Repository 不被重生成覆盖。 +先升级依赖,再使用同版 CLI。新生成代码最低要求本版;已有固定错误、v0.10.0 目录、无参数函数和旧生成代码继续支持。模板错误一旦声明参数,修改其顺序/类型会改变生成函数签名,项目需同步修改调用方。手写文件仍受重生成保护。 -## 接入 +从 [README](README.md) 接入,完整规则见 [错误目录与模板参数](errors/README.md),可直接生成的契约见 [errorParams.api](examples/contracts/errorParams.api)。 -- 手写项目:`omni.New(omni.WithErrors(catalog), omni.WithResponse(responseConfig))`,业务返回 `omnierrors.ByCode(key)`,按需 `.WithCause(err)`。 -- 契约项目:`.api errorCatalog` 定义一次,路由 `@errors` 或 `@server errors` 引用,生成 `apierrors.UsernameInvalid()` 等函数、Catalog、路由策略及 OpenAPI。 -- 项目目录的 `defaultStatus` 可以设为 200,单条错误可设置 403、409 等;默认只作用于目录条目,不改变未配置系统错误的兜底。 -- 逻辑标识必须唯一,对外业务码可以重复,支持字符串/int32/int64;整数精度与所选响应类型在加载/编译时校验。 -- HTTP 200 失败使用错误响应模板,OpenAPI 的 200 使用 anyOf 保留成功 DTO 和错误体;同码错误保留不同示例。 -- App 和路由目录隔离、初始化复制;每次生成/解析错误都返回独立对象。未知标识转系统兜底,默认公开响应不暴露 cause。 +## 能力与边界 -完整接入从 [README](README.md) 开始;目录/作用域/配置边界见 [错误目录](errors/README.md);可直接生成的例子见 [errors.api](examples/contracts/errors.api)。 - -## 配置边界 - -生成路由的目录和响应以 `.api` 为准,优先于 App 默认配置;ByCode 需要当前目录中存在该标识。使用生成函数后仍须声明实际路由的 @errors,保持文档准确。不同路由可使用不同码类型与返回格式。 - -目录与响应模板分开,自定义 Encoder 继续控制最终 HTTP 状态和文本/XML 等协议。直接输出与已提交响应不会被再次包装。旧同标识 Codes/Messages 若与新目录冲突会明确拒绝。 - -访问日志记录实际提交状态,并带 failed/errorKey/可用的 businessCode。指标与链路识别 HTTP 200 业务失败;标准中间件和本地/共享幂等重放保留错误标识,旧缓存缺少标记时不补推历史状态。业务代码中的散落提示仍需业务项目按目录逐步迁移;本轮没有改写 eTax/ePay 业务逻辑。 +- 有序 `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 业务代码;业务侧需升级版本并声明自己的通用校验错误。 ## 验证入口 @@ -40,13 +33,12 @@ go test ./... GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./... go vet ./... go build ./... -python3 scripts/verifyErrorCatalogRelease.py --version v0.10.0-preview -python3 scripts/verifyCompatibilityRelease.py --version v0.10.0-preview \ - --report performance/errorCatalogCompatibilityRemote.json +# 使用已有 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 ``` -测试覆盖多 App 隔离、并发原因隔离、精确 int64、重复/非法定义拒绝、200 失败与 403 覆盖、未知错误兜底、已提交响应不追加、文本回执、访问日志、指标/链路、标准桥接、本地与序列化共享幂等存储,以及真实生成项目的 early middleware、raw 输出和重生成保护。远程脚本从全新缓存安装 CLI,检查固定版本、模块校验和与没有本地 replace。 - -本版结果见 [统一错误目录验收](performance/errorCatalogAcceptance.md)。 - -历史 Connector/HTTP 配置验收保留在 [v0.9.1 记录](performance/connectorAcceptance.md),历史版本变化见 [CHANGELOG](CHANGELOG.md)。 +验收结果见 [提示模板验收](performance/errorParamsAcceptance.md)。历史 [v0.10.0 错误目录验收](performance/errorCatalogAcceptance.md) 保留。 diff --git a/app/errorParams_test.go b/app/errorParams_test.go new file mode 100644 index 0000000..3d16b7f --- /dev/null +++ b/app/errorParams_test.go @@ -0,0 +1,186 @@ +package app_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "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/logx" + "git.yuntuo.hk/k2safe/go-omni/metrics" + "git.yuntuo.hk/k2safe/go-omni/middleware" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +func TestTemplateErrorsAcrossPoliciesAndReplay(t *testing.T) { + var logs bytes.Buffer + lc := logx.Default() + lc.Writer = &logs + logger, err := logx.New(lc) + if err != nil { + t.Fatal(err) + } + logx.SetDefault(logger) + defer logx.SetDefault(nil) + defer logger.Close() + cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"TOO_LONG": { + Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}, + }}} + registry := apierrors.MustRegistry(cat) + encoder := response.MustWithErrors(cat, response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"})) + app := omni.New(omni.WithLogger(true)) + defer app.Close() + meter := metrics.New() + app.Use(meter.Middleware()) + // The clone uses the route policy even though the App has a different default. + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { + err := next(c) + if f := c.Failure(); f != nil && strings.Contains(f.Message, "{label}") { + t.Error("unrendered failure", f) + } + return err + } + }) + bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) + }) + calls := 0 + handler := func(c *httpx.Context) error { + calls++ + values := map[string]any{"label": "企业名称", "max": 100} + if c.Header("X-Label") == "remark" { + values = map[string]any{"label": "备注", "max": 500} + } + switch c.Header("X-Failure") { + case "missing": + delete(values, "max") + case "type": + values["max"] = "private-value" + } + e := apierrors.ByCode("TOO_LONG").WithParams(values).WithCause(errors.New("private cause")) + if c.Header("X-Resolved") == "true" { + return registry.Resolve(e) + } + return e + } + app.HandleWithResponse("POST", "/direct", encoder, handler, bridge) + for _, shared := range []bool{false, true} { + cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }} + path := "/local" + if shared { + path = "/shared" + cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}} + } + idem, err := middleware.NewIdempotency(cfg) + if err != nil { + t.Fatal(err) + } + app.HandleWithResponse("POST", path, encoder, handler, idem.Middleware(), bridge) + } + check := func(path, label, key, kind string, status int, want string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", path, nil) + r.Header.Set("Idempotency-Key", key) + r.Header.Set("X-Label", label) + r.Header.Set("X-Failure", kind) + r.Header.Set("X-Resolved", "true") + app.ServeHTTP(w, r) + if w.Code != status || !strings.Contains(w.Body.String(), want) || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "{label}") { + t.Fatal(path, w.Code, w.Body.String()) + } + if status == 200 && (!strings.Contains(w.Body.String(), `"data":null`) || !strings.Contains(w.Body.String(), `"code":400002`)) { + t.Fatal(w.Body.String()) + } + return w + } + for _, path := range []string{"/direct", "/local", "/shared"} { + for _, label := range []string{"company", "remark"} { + want := "企业名称不能超过100个字符" + if label == "remark" { + want = "备注不能超过500个字符" + } + first := check(path, label, label, "", 200, want) + if path != "/direct" { + before := calls + second := check(path, label, label, "", 200, want) + if calls != before || first.Body.String() != second.Body.String() || second.Header().Get("Idempotency-Replayed") != "true" { + t.Fatal("bad replay") + } + } + } + } + check("/direct", "company", "", "missing", 500, "internal server error") + check("/direct", "company", "", "type", 500, "internal server error") + stats := meter.Snapshot() + if stats.Errors != 12 || stats.StatusCounts["200"] != 10 || stats.StatusCounts["500"] != 2 { + t.Fatal(stats) + } + seen, diagnostics := 0, 0 + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + var entry map[string]any + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatal(err) + } + if entry["msg"] != "http.request" { + continue + } + seen++ + if entry["failed"] != true { + t.Fatal(entry) + } + if entry["status"] == float64(200) && (entry["errorKey"] != "TOO_LONG" || entry["businessCode"] != float64(400002)) { + t.Fatal(entry) + } + if entry["errorParameter"] != nil { + diagnostics++ + if strings.Contains(entry["errorParameter"].(string), "private-value") { + t.Fatal(entry) + } + } + } + if seen != 12 || diagnostics != 2 { + t.Fatal(seen, diagnostics) + } +} + +func TestResolvedFailureReplaySurvivesCatalogChanges(t *testing.T) { + store := &catalogStore{records: map[string]middleware.IdempotencyRecord{}} + var observed []*apierrors.APIError + makeApp := func(message string) *omni.App { + cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"T": {Code: "code", Message: message, Params: []apierrors.Parameter{{Name: "label", Type: "string"}}}}} + app := omni.New(omni.WithErrors(cat)) + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { err := next(c); observed = append(observed, c.Failure()); return err } + }) + idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: store, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}) + if err != nil { + t.Fatal(err) + } + app.POST("/", func(c *httpx.Context) error { + return apierrors.ByCode("T").WithParams(map[string]any{"label": "企业"}) + }, idem.Middleware()) + return app + } + for _, message := range []string{"{label}原文案", "{label}新文案"} { + app := makeApp(message) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", nil) + r.Header.Set("Idempotency-Key", "same") + app.ServeHTTP(w, r) + app.Close() + if !strings.Contains(w.Body.String(), "企业原文案") { + t.Fatal(w.Body.String()) + } + } + if len(observed) != 2 || observed[0].Message != "企业原文案" || observed[1].Message != "企业原文案" || observed[1].Code != "T" { + t.Fatal(observed) + } +} diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index b610db8..3a57ec3 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -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 diff --git a/cmd/go-omni/apigen/contractMetadata.go b/cmd/go-omni/apigen/contractMetadata.go index 866cf44..377e418 100644 --- a/cmd/go-omni/apigen/contractMetadata.go +++ b/cmd/go-omni/apigen/contractMetadata.go @@ -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]) diff --git a/cmd/go-omni/apigen/declarations.go b/cmd/go-omni/apigen/declarations.go index 86df72d..f96740f 100644 --- a/cmd/go-omni/apigen/declarations.go +++ b/cmd/go-omni/apigen/declarations.go @@ -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 diff --git a/cmd/go-omni/apigen/errorCatalog.go b/cmd/go-omni/apigen/errorCatalog.go index be69bf0..56817d8 100644 --- a/cmd/go-omni/apigen/errorCatalog.go +++ b/cmd/go-omni/apigen/errorCatalog.go @@ -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 } diff --git a/cmd/go-omni/apigen/errorCatalog_test.go b/cmd/go-omni/apigen/errorCatalog_test.go index 8f14ad6..f550b5f 100644 --- a/cmd/go-omni/apigen/errorCatalog_test.go +++ b/cmd/go-omni/apigen/errorCatalog_test.go @@ -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) diff --git a/cmd/go-omni/apigen/errorParams_test.go b/cmd/go-omni/apigen/errorParams_test.go new file mode 100644 index 0000000..625cc92 --- /dev/null +++ b/cmd/go-omni/apigen/errorParams_test.go @@ -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") + } +} diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 5387654..1929f2d 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -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) } diff --git a/cmd/go-omni/apigen/testdata/errorParams/consumer_test.go b/cmd/go-omni/apigen/testdata/errorParams/consumer_test.go new file mode 100644 index 0000000..f41a786 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/errorParams/consumer_test.go @@ -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") + } + } + } +} diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 7fe5e59..50831d4 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.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. diff --git a/config/errorParams_test.go b/config/errorParams_test.go new file mode 100644 index 0000000..e4986f5 --- /dev/null +++ b/config/errorParams_test.go @@ -0,0 +1,44 @@ +package config_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "git.yuntuo.hk/k2safe/go-omni/config" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" +) + +func TestTemplateConfigFiles(t *testing.T) { + for _, file := range []struct{ name, body string }{ + {"config.json", `{"errors":{"defaultStatus":200,"definitions":{"LIMIT":{"code":"limit","message":"{label}:{max}","params":[{"name":"label","type":"string","example":"字段"},{"name":"max","type":"int64","example":9223372036854775807}]}}}}`}, + {"config.yaml", "errors:\n defaultStatus: 200\n definitions:\n LIMIT:\n code: limit\n message: '{label}:{max}'\n params:\n - name: label\n type: string\n example: 字段\n - name: max\n type: int64\n example: 9223372036854775807\n"}, + } { + t.Run(file.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), file.name) + if err := os.WriteFile(path, []byte(file.body), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadFile(path, "TEMPLATE_TEST_") + if err != nil { + t.Fatal(err) + } + r := apierrors.MustRegistry(cfg.Errors) + if r.Example("LIMIT").Message != "字段:9223372036854775807" { + t.Fatal(r.Example("LIMIT")) + } + cfg.Errors.Definitions["LIMIT"].Params[0].Name = "mutated" + var supplied map[string]any + if err := apierrors.DecodeCatalogJSON([]byte(`{"label":"字段","max":9223372036854775807}`), &supplied); err != nil { + t.Fatal(err) + } + if _, ok := supplied["max"].(json.Number); !ok { + t.Fatal("number precision lost") + } + if r.Resolve(apierrors.ByCode("LIMIT").WithParams(supplied)).Message != "字段:9223372036854775807" { + t.Fatal("parameter decode or snapshot failure") + } + }) + } +} diff --git a/errors/README.md b/errors/README.md index a153dd8..2853390 100644 --- a/errors/README.md +++ b/errors/README.md @@ -110,6 +110,68 @@ service account-api { 生成路由的目录和响应由 `.api` 管理;YAML 或 App 默认策略不覆盖它。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 并选择兼容的 `WithResponse`。新项目和重复生成都会自动绑定路由目录,无需手动修改 main;已有手写文件保持不动。 +## 提示模板参数(v0.11.0-preview) + +模板属于错误目录,业务只传具体值。参数用**有序列表**声明,顺序就是生成函数的参数顺序;每个参数均为必传,`example` 只用于文档。 + +```text +errorCatalog CommonErrors ( + defaultStatus: 200 + errors: { + "TextTooLong": { + "key": "TEXT_TOO_LONG", + "code": 400002, + "message": "{label}不能超过{max}个字符", + "params": [ + {"name": "label", "type": "string", "example": "企业名称"}, + {"name": "max", "type": "int", "example": 100} + ] + } + } +) +``` + +与数字码匹配的公共响应及路由声明见完整 [errorParams.api](../examples/contracts/errorParams.api)。生成后: + +```go +apierrors.TextTooLong("企业名称", 100) // Message:企业名称不能超过100个字符 +apierrors.TextTooLong("备注", 500) // Message:备注不能超过500个字符 +apierrors.UsernameInvalid() // 原有固定错误保持无参数 +``` + +生成函数在返回前完成解析,非 HTTP Service、任务或 CLI 同样能直接读取完整 Message。调用 `.WithCause(err)` 保留内部原因及 `errors.Is/As` 错误链,不自动把原因加入提示。每次返回新对象,不能把可变错误保存成共享单例。 + +运行时入口使用同一个解析器: + +```go +err := omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{ + "label": "企业名称", + "max": 100, +}) +// HTTP:将 err 返回,当前 App/路由目录负责解析。 +// 非 HTTP:使用项目自己的目录显式解析。 +registry := omnierrors.MustRegistry(apierrors.Catalog()) +resolved := registry.Resolve(err) +fmt.Println(resolved.Message) // 企业名称不能超过100个字符 +``` + +Go 配置中的 `Definition.Params` 是 `[]omnierrors.Parameter`,JSON/YAML 使用相同的 `params` 数组。目录在初始化时复制并编译模板;`WithParams` 返回新错误并复制参数,之后修改原 map 不会改变该错误。重复解析、路由策略和 Context 缓冲克隆保留参数。 + +规则如下: + +- 参数名使用 lowerCamelCase,并且不是 Go 关键字。顺序是调用 API 的一部分,重排或变更类型需要同步调整调用方。 +- 支持 `string`、`bool`、`int`、`int32`、`int64`、`float64`。运行时允许整数类型之间按声明范围归一化,拒绝将字符串或浮点数隐式转成整数;float64 要求有限数值。`int` 范围随 Go 目标平台变化,需要固定位宽时选 int32/int64。 +- 只接受标量,不接受 nil、切片、map、指针、任意对象或 Stringer。通过 JSON 构建运行时参数时使用 `Decoder.UseNumber()`,避免丢失大整数精度。 +- 模板使用 `{name}`;同一参数可重复出现。`{{` / `}}` 表示字面花括号。插入值不会再次解析,即使 label 本身包含 `{max}`,也只是普通文本。 +- **省略 params** 表示旧固定提示,字面花括号不变。不接受空 params 数组;重复参数、未声明占位符、未使用参数、不匹配的花括号、非法类型及错误的 example 会在目录加载/契约校验时失败。 +- 运行时缺参、多传参数、类型或范围错误转为系统错误兜底;不会将原始模板或参数值作为诊断发给客户端。内部 cause 中可通过 `errors.As` 获取 `*omnierrors.ParamError`,访问日志带 `errorParameter` 诊断,不自动记录参数值。 +- 项目可以配置固定的 INTERNAL_ERROR 兜底;若它自身也需要参数且无法解析,则回退框架固定 500,避免递归失败。 +- 模板错误的提示只放目录中;同标识再配置旧 `messages` 会被拒绝。响应字段、HTTP 状态、`data:null` 仍由项目策略决定,例如 `dataMode: always` 保留空 data。 + +OpenAPI 的 `x-omni-errors` 保留模板和有序参数定义,响应 examples 使用同一个解析器生成具体文案。未提供 example 时使用稳定样例(string 使用参数名,bool 使用 false,数值使用 1);这些样例不充当运行时默认值,也不是新增 HTTP 请求参数。 + +幂等重放保存当次**已解析的公开错误快照**,保留原响应与失败信息。目录更新后,仍在有效期内的重放不会被新文案覆盖;快照不持久化原始参数或内部 cause。整数业务码以不受 JSON 中间层浮点转换影响的方式保存。旧缓存仅保留已有错误标识,不能补推当时缺失的提示与业务码。共享存储的错误快照另受 MaxResponseBytes 限额约束,超限保留不可重放记录,避免重复执行业务。 + ## 特殊协议与失败信息 `response.WithErrors(catalog, encoder)` / `MustWithErrors` 可绑定路由专用策略,再通过 `HandleWithResponse` 注册。内置模板校验目录码类型与旧映射;自定义 Encoder 自行决定最终 HTTP 状态、媒体类型和字段结构,可将失败转换成第三方要求的文本/XML 回执。一个 Encoder 作用域不叠加不同目录;外层空目录或相同目录不会覆盖已有目录。 @@ -123,11 +185,11 @@ Encoder 的 `response.Result` 包含逻辑 `Code`、对外 `BusinessCode`、公 ## 升级 ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview +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-omni api generate --dry-run go-omni api generate go test ./... ``` -先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.10.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。 +先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.11.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。 diff --git a/errors/catalog.go b/errors/catalog.go index f04940e..ce62bac 100644 --- a/errors/catalog.go +++ b/errors/catalog.go @@ -15,9 +15,10 @@ import ( // the Catalog map key is the distinct logical identifier used by ByCode. // Nil Code retains legacy response code mapping (useful when migrating). type Definition struct { - Status int `json:"status,omitempty"` - Code any `json:"code,omitempty"` - Message string `json:"message"` + Status int `json:"status,omitempty"` + Code any `json:"code,omitempty"` + Message string `json:"message"` + Params []Parameter `json:"params,omitempty"` } type Catalog struct { @@ -98,6 +99,11 @@ func (c Catalog) Clone() Catalog { out := c out.Definitions = make(map[string]Definition, len(c.Definitions)) for key, d := range c.Definitions { + if d.Params != nil { + params := make([]Parameter, len(d.Params)) + copy(params, d.Params) + d.Params = params + } out.Definitions[key] = d } return out @@ -105,7 +111,10 @@ func (c Catalog) Clone() Catalog { // Registry is immutable after construction and may be shared across requests. // It has no process-global state. Every Resolve call returns a fresh error. -type Registry struct{ catalog Catalog } +type Registry struct { + catalog Catalog + messages map[string][]messagePart +} func NewRegistry(c Catalog) (*Registry, error) { c = c.Clone() @@ -115,6 +124,7 @@ func NewRegistry(c Catalog) (*Registry, error) { if !ValidErrorStatus(c.DefaultStatus) { return nil, fmt.Errorf("errors: invalid defaultStatus %d", c.DefaultStatus) } + messages := map[string][]messagePart{} for key, d := range c.Definitions { if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") { return nil, fmt.Errorf("errors: invalid logical identifier %q", key) @@ -135,9 +145,14 @@ func NewRegistry(c Catalog) (*Registry, error) { } d.Code = code } + parts, err := compileMessage(&d) + if err != nil { + return nil, fmt.Errorf("errors: %s: %w", key, err) + } + messages[key] = parts c.Definitions[key] = d } - return &Registry{catalog: c}, nil + return &Registry{catalog: c, messages: messages}, nil } // ValidErrorStatus permits final responses with a JSON body, including HTTP 200. @@ -171,23 +186,41 @@ func (r *Registry) Resolve(err error) *APIError { } var source *APIError if !stdErrors.As(err, &source) || source == nil { - source = Internal(err) + return r.internal(err) + } + if source.paramErr != nil { + return r.internal(stdErrors.Join(source.paramErr, err)) } if r != nil { if d, ok := r.catalog.Definitions[source.Code]; ok { - return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: source.Cause} + message, paramErr := r.render(source.Code, d, source.params) + if paramErr != nil { + return r.internal(stdErrors.Join(paramErr, err)) + } + out := source.Clone() + out.Status, out.Code, out.Message, out.BusinessCode = d.Status, source.Code, message, d.Code + out.reference = false + return out } } if source.reference { - source = Internal(err) - if r != nil { - if d, ok := r.catalog.Definitions[source.Code]; ok { - return &APIError{Status: d.Status, Code: source.Code, Message: d.Message, BusinessCode: d.Code, Cause: err} + return r.internal(err) + } + return source.Clone() +} + +// Internal fallback is deliberately bounded: a misconfigured parameterized +// INTERNAL_ERROR cannot recursively fail while reporting a rendering problem. +func (r *Registry) internal(cause error) *APIError { + out := Internal(cause) + if r != nil { + if d, ok := r.catalog.Definitions[out.Code]; ok { + if message, err := r.render(out.Code, d, nil); err == nil { + out.Status, out.Message, out.BusinessCode = d.Status, message, d.Code } } } - copy := *source - return © + return out } func normalizeCatalogCode(value any) (any, error) { diff --git a/errors/errors.go b/errors/errors.go index dd06270..4140c42 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -6,7 +6,8 @@ import ( ) // APIError is the public error shape used by handlers and middleware. -// Cause is retained for logging and unwrapping, but is never serialized. +// Cause is retained for logging and unwrapping; default response encoders and +// replay snapshots never serialize it. type APIError struct { Status int Code string @@ -14,6 +15,8 @@ type APIError struct { Cause error BusinessCode any // Optional public code resolved from a project catalog. reference bool + params map[string]any + paramErr error } func (e *APIError) Error() string { diff --git a/errors/params.go b/errors/params.go new file mode 100644 index 0000000..dc0abf9 --- /dev/null +++ b/errors/params.go @@ -0,0 +1,272 @@ +package apierrors + +import ( + "encoding/json" + "fmt" + "go/token" + "math" + "reflect" + "regexp" + "strconv" + "strings" +) + +// Parameter is ordered: declaration order is the generated function's argument +// order. Example is documentation-only and never substitutes a missing value. +type Parameter struct { + Name string `json:"name"` + Type string `json:"type"` + Example any `json:"example,omitempty"` +} + +type messagePart struct{ text, param string } + +var parameterName = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`) + +// ParamError describes a programming/configuration error without including the +// supplied value. It remains available through errors.As on the internal cause. +type ParamError struct{ Code, Param, Problem string } + +func (e *ParamError) Error() string { + return fmt.Sprintf("errors: %s parameter %q: %s", e.Code, e.Param, e.Problem) +} + +// WithParams returns a detached error. It never retains the caller's map or any +// mutable parameter value. Catalog-specific validation happens during Resolve. +func (e *APIError) WithParams(values map[string]any) *APIError { + out := e.Clone() + out.params = make(map[string]any, len(values)) + out.paramErr = nil + for name, value := range values { + scalar, err := copyScalar(value) + if err != nil { + out.paramErr = &ParamError{Code: out.Code, Param: name, Problem: "expected a scalar value"} + continue + } + out.params[name] = scalar + } + return out +} + +// Clone detaches request-specific parameters; Cause remains an error chain. +func (e *APIError) Clone() *APIError { + if e == nil { + return nil + } + out := *e + out.params = make(map[string]any, len(e.params)) + for k, v := range e.params { + out.params[k] = v + } + return &out +} + +func copyScalar(v any) (any, error) { + if n, ok := v.(json.Number); ok { + return n, nil + } + if v == nil { + return nil, fmt.Errorf("nil scalar") + } + r := reflect.ValueOf(v) + switch r.Kind() { + case reflect.String: + return r.String(), nil + case reflect.Bool: + return r.Bool(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return r.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return r.Uint(), nil + case reflect.Float32, reflect.Float64: + return r.Float(), nil + } + return nil, fmt.Errorf("unsupported scalar") +} +func paramValue(kind string, v any) (any, error) { + value, err := copyScalar(v) + if err != nil { + return nil, err + } + switch kind { + case "string": + if s, ok := value.(string); ok { + return s, nil + } + case "bool": + if b, ok := value.(bool); ok { + return b, nil + } + case "int", "int32", "int64": + var n int64 + switch x := value.(type) { + case int64: + n = x + case uint64: + if x > math.MaxInt64 { + return nil, fmt.Errorf("integer overflow") + } + n = int64(x) + case json.Number: + n, err = x.Int64() + if err != nil { + return nil, fmt.Errorf("expected exact integer") + } + default: + return nil, fmt.Errorf("expected %s", kind) + } + if (kind == "int32" || kind == "int" && strconv.IntSize == 32) && (n < math.MinInt32 || n > math.MaxInt32) { + return nil, fmt.Errorf("%s overflow", kind) + } + return n, nil + case "float64": + var n float64 + switch x := value.(type) { + case float64: + n = x + case json.Number: + n, err = x.Float64() + if err != nil { + return nil, fmt.Errorf("expected finite float64") + } + default: + return nil, fmt.Errorf("expected float64") + } + if math.IsNaN(n) || math.IsInf(n, 0) { + return nil, fmt.Errorf("expected finite float64") + } + return n, nil + } + return nil, fmt.Errorf("expected %s", kind) +} + +func compileMessage(d *Definition) ([]messagePart, error) { + if d.Params == nil { + return []messagePart{{text: d.Message}}, nil + } + if len(d.Params) == 0 { + return nil, fmt.Errorf("params must be nonempty; omit params for a fixed message") + } + declared := map[string]bool{} + for i, p := range d.Params { + if !parameterName.MatchString(p.Name) || token.Lookup(p.Name).IsKeyword() || declared[p.Name] { + return nil, fmt.Errorf("invalid or duplicate parameter %q", p.Name) + } + switch p.Type { + case "string", "bool", "int", "int32", "int64", "float64": + default: + return nil, fmt.Errorf("unsupported parameter type %q", p.Type) + } + declared[p.Name] = true + if p.Example != nil { + example, err := paramValue(p.Type, p.Example) + if err != nil { + return nil, fmt.Errorf("example for %s: %w", p.Name, err) + } + d.Params[i].Example = example + } + } + var parts []messagePart + var literal strings.Builder + used := map[string]bool{} + flush := func() { + if literal.Len() > 0 { + parts = append(parts, messagePart{text: literal.String()}) + literal.Reset() + } + } + for i := 0; i < len(d.Message); { + ch := d.Message[i] + if ch != '{' && ch != '}' { + literal.WriteByte(ch) + i++ + continue + } + if i+1 < len(d.Message) && d.Message[i+1] == ch { + literal.WriteByte(ch) + i += 2 + continue + } + if ch == '}' { + return nil, fmt.Errorf("unescaped closing brace") + } + end := strings.IndexByte(d.Message[i+1:], '}') + if end < 0 { + return nil, fmt.Errorf("unclosed placeholder") + } + name := d.Message[i+1 : i+1+end] + if !declared[name] { + return nil, fmt.Errorf("undeclared placeholder %q", name) + } + flush() + parts = append(parts, messagePart{param: name}) + used[name] = true + i += end + 2 + } + flush() + for _, p := range d.Params { + if !used[p.Name] { + return nil, fmt.Errorf("unused parameter %q", p.Name) + } + } + return parts, nil +} + +func (r *Registry) render(key string, d Definition, params map[string]any) (string, error) { + values := make(map[string]any, len(d.Params)) + for _, p := range d.Params { + v, ok := params[p.Name] + if !ok { + return "", &ParamError{key, p.Name, "missing value"} + } + normalized, err := paramValue(p.Type, v) + if err != nil { + return "", &ParamError{key, p.Name, err.Error()} + } + values[p.Name] = normalized + } + for name := range params { + if _, ok := values[name]; !ok { + return "", &ParamError{key, name, "not declared"} + } + } + var result strings.Builder + for _, part := range r.messages[key] { + if part.param != "" { + fmt.Fprint(&result, values[part.param]) + } else { + result.WriteString(part.text) + } + } + return result.String(), nil +} + +// Example resolves documentation values using the same path as runtime errors. +// Missing examples receive deterministic samples, never runtime defaults. +func (r *Registry) Example(key string) *APIError { + if r == nil { + return r.Resolve(ByCode(key)) + } + d, ok := r.catalog.Definitions[key] + if !ok { + return r.Resolve(ByCode(key)) + } + values := map[string]any{} + for _, p := range d.Params { + v := p.Example + if v == nil { + switch p.Type { + case "string": + v = p.Name + case "bool": + v = false + case "float64": + v = float64(1) + default: + v = int64(1) + } + } + values[p.Name] = v + } + return r.Resolve(ByCode(key).WithParams(values)) +} diff --git a/errors/params_test.go b/errors/params_test.go new file mode 100644 index 0000000..e479a08 --- /dev/null +++ b/errors/params_test.go @@ -0,0 +1,160 @@ +package apierrors_test + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "sync" + "testing" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" +) + +func templateCatalog() apierrors.Catalog { + return apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{ + "TEXT_TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}, + "FIXED": {Code: 400003, Message: "literal {unchanged}"}, + }} +} +func TestMessageParamsIsolationAndResolution(t *testing.T) { + catalog := templateCatalog() + r := apierrors.MustRegistry(catalog) + catalog.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool" + r.Catalog().Definitions["TEXT_TOO_LONG"].Params[0].Name = "changed" + private := errors.New("private cause") + values := map[string]any{"label": "企业名称", "max": 100} + reference := apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(private) + values["label"] = "changed" + first := r.Resolve(fmt.Errorf("wrapped: %w", reference)) + for n := 0; n < 3; n++ { + first = r.Resolve(first) + if first.Message != "企业名称不能超过100个字符" || first.Status != 200 || first.BusinessCode != int64(400002) || !errors.Is(first, private) { + t.Fatal(first) + } + } + second := r.Resolve(first.WithParams(map[string]any{"label": "备注", "max": 500})) + if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" { + t.Fatal(first, second) + } + if r.Example("TEXT_TOO_LONG").Message != first.Message { + t.Fatal("example and runtime differ") + } + if r.Resolve(apierrors.ByCode("FIXED")).Message != "literal {unchanged}" { + t.Fatal("legacy braces changed") + } + var wg sync.WaitGroup + for n := 0; n < 80; n++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + label, max := "企业名称", 100 + if n%2 == 1 { + label, max = "备注", 500 + } + got := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": label, "max": max}).WithCause(private)) + if got.Message != fmt.Sprintf("%s不能超过%d个字符", label, max) || !errors.Is(got, private) { + t.Error(got) + } + }(n) + } + wg.Wait() +} +func TestInvalidTemplateParamsFailClosed(t *testing.T) { + r := apierrors.MustRegistry(templateCatalog()) + cause := errors.New("private") + for _, values := range []map[string]any{ + nil, {"label": "x"}, {"label": "x", "max": "100"}, {"label": 100, "max": 100}, + {"label": "x", "max": 100.0}, {"label": "x", "max": 100, "extra": "private-value"}, + {"label": "x", "max": nil}, {"label": map[string]any{"secret": "private-value"}, "max": 100}, + } { + result := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(cause)) + var diagnostic *apierrors.ParamError + if result.Code != "INTERNAL_ERROR" || result.Status != 500 || strings.Contains(result.Message, "{") || !errors.As(result, &diagnostic) || !errors.Is(result, cause) { + t.Fatal(result) + } + if strings.Contains(diagnostic.Error(), "private-value") { + t.Fatal("diagnostic leaked value") + } + } + // A parameterized fallback cannot recurse or expose its template. + c := templateCatalog() + c.Definitions["INTERNAL_ERROR"] = c.Definitions["TEXT_TOO_LONG"] + if result := apierrors.MustRegistry(c).Resolve(errors.New("private")); result.Message != "internal server error" || result.Status != 500 { + t.Fatal(result) + } + for _, change := range []func(*apierrors.Definition){ + func(d *apierrors.Definition) { d.Message = "{label}{missing}" }, + func(d *apierrors.Definition) { d.Message = "{label}" }, + func(d *apierrors.Definition) { d.Message = "{label}{max" }, + func(d *apierrors.Definition) { d.Message = "{label}{max}}" }, + func(d *apierrors.Definition) { d.Params[1].Name = "label" }, + func(d *apierrors.Definition) { d.Params[1].Name = "type" }, + func(d *apierrors.Definition) { d.Params[1].Type = "interface{}" }, + func(d *apierrors.Definition) { d.Params[1].Example = "100" }, + func(d *apierrors.Definition) { d.Params = []apierrors.Parameter{} }, + } { + c := templateCatalog() + d := c.Definitions["TEXT_TOO_LONG"] + change(&d) + c.Definitions["TEXT_TOO_LONG"] = d + if _, err := apierrors.NewRegistry(c); err == nil { + t.Fatal("accepted invalid template", d) + } + } +} +func TestParamTypesEscapingAndSnapshot(t *testing.T) { + c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"T": {Code: "t", Message: "{{literal}} {value} {value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}}}} + r := apierrors.MustRegistry(c) + got := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": "{other}"})) + if got.Message != "{literal} {other} {other}" { + t.Fatal("recursive substitution", got) + } + for _, test := range []struct { + kind string + good, bad any + want string + }{ + {"int32", int64(math.MaxInt32), int64(math.MaxInt32) + 1, "2147483647"}, + {"int64", json.Number("9223372036854775807"), json.Number("9223372036854775808"), "9223372036854775807"}, + {"bool", true, "true", "true"}, {"float64", float64(1.25), math.Inf(1), "1.25"}, + } { + d := c.Definitions["T"] + d.Params = []apierrors.Parameter{{Name: "value", Type: test.kind}} + c.Definitions["T"] = d + r = apierrors.MustRegistry(c) + if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.good})); x.Message != "{literal} "+test.want+" "+test.want { + t.Fatal(test.kind, x) + } + if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.bad})); x.Status != 500 { + t.Fatal(test.kind, x) + } + } + original := apierrors.New(200, "T", "complete").WithCause(errors.New("private")) + original.BusinessCode = int64(math.MaxInt64) + raw, err := json.Marshal(original.Snapshot()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "params") { + t.Fatal(string(raw)) + } + // Simulate a store rewriting its envelope with ordinary JSON numbers. + var storage map[string]any + if err = json.Unmarshal(raw, &storage); err != nil { + t.Fatal(err) + } + raw, err = json.Marshal(storage) + if err != nil { + t.Fatal(err) + } + var snapshot apierrors.Snapshot + if err = json.Unmarshal(raw, &snapshot); err != nil { + t.Fatal(err) + } + restored := snapshot.ErrorValue() + if restored.Cause != nil || restored.Message != "complete" || restored.BusinessCode != int64(math.MaxInt64) { + t.Fatal(restored) + } +} diff --git a/errors/snapshot.go b/errors/snapshot.go new file mode 100644 index 0000000..d5cb82f --- /dev/null +++ b/errors/snapshot.go @@ -0,0 +1,71 @@ +package apierrors + +import "encoding/json" + +// Snapshot contains only the resolved public error, suitable for replay +// metadata. Parameters and internal causes are deliberately excluded. +type Snapshot struct { + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + BusinessCode any `json:"businessCode,omitempty"` +} + +// Encode the public code as opaque JSON text so shared stores (including Redis +// Lua/cjson) cannot round an int64 while rewriting their record envelopes. +func (s Snapshot) MarshalJSON() ([]byte, error) { + var encoded string + if s.BusinessCode != nil { + code, err := normalizeCatalogCode(s.BusinessCode) + if err != nil { + return nil, err + } + raw, err := json.Marshal(code) + if err != nil { + return nil, err + } + encoded = string(raw) + } + return json.Marshal(struct { + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + BusinessCodeJSON string `json:"businessCodeJson,omitempty"` + }{s.Status, s.Code, s.Message, encoded}) +} +func (s *Snapshot) UnmarshalJSON(data []byte) error { + type plain Snapshot + var out struct { + plain + BusinessCodeJSON string `json:"businessCodeJson,omitempty"` + } + if err := DecodeCatalogJSON(data, &out); err != nil { + return err + } + if out.BusinessCodeJSON != "" { + if err := DecodeCatalogJSON([]byte(out.BusinessCodeJSON), &out.BusinessCode); err != nil { + return err + } + } + if out.BusinessCode != nil { + code, err := normalizeCatalogCode(out.BusinessCode) + if err != nil { + return err + } + out.BusinessCode = code + } + *s = Snapshot(out.plain) + return nil +} +func (e *APIError) Snapshot() *Snapshot { + if e == nil { + return nil + } + return &Snapshot{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode} +} +func (s *Snapshot) ErrorValue() *APIError { + if s == nil { + return nil + } + return &APIError{Status: s.Status, Code: s.Code, Message: s.Message, BusinessCode: s.BusinessCode} +} diff --git a/examples/contracts/errorParams.api b/examples/contracts/errorParams.api new file mode 100644 index 0000000..a7e0b75 --- /dev/null +++ b/examples/contracts/errorParams.api @@ -0,0 +1,48 @@ +syntax = "v1" + +errorCatalog CommonErrors ( + defaultStatus: 200 + errors: { + "TextTooLong": { + "key": "TEXT_TOO_LONG", + "code": 400002, + "message": "{label}不能超过{max}个字符", + "params": [ + {"name": "label", "type": "string", "example": "企业名称"}, + {"name": "max", "type": "int", "example": 100} + ] + }, + "UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400003, "message": "用户名格式不正确"}, + "ResourceDenied": { + "key": "RESOURCE_DENIED", "status": 403, "code": 403001, + "message": "{label}不可用", + "params": [{"name": "label", "type": "string", "example": "资源"}] + } + } +) + +response CommonResponse ( + codeType: int32 + successCode: 0 + errorCode: 9999 + body: {"code": "${code}", "msg": "${message}", "data": "${data}"} + dataMode: always +) + +type TokenResponse { + Token string `json:"token"` +} + +@server ( + group: account + response: CommonResponse + errors: TextTooLong, UsernameInvalid, ResourceDenied +) +service error-params-api { + @handler token + post /token returns (TokenResponse) + + @response raw + @handler raw + get /raw returns (TokenResponse) +} diff --git a/httpx/context.go b/httpx/context.go index a0f1caf..e7638ae 100644 --- a/httpx/context.go +++ b/httpx/context.go @@ -314,11 +314,14 @@ func (c *Context) ResolveError(err error) *apierrors.APIError { // RecordError preserves failure semantics when a middleware commits buffered bytes. func (c *Context) RecordError(err error) { c.failure = c.ResolveError(err) } +// RecordFailure propagates an already resolved failure without reapplying a +// different parent/catalog policy. Used with committed or buffered responses. +func (c *Context) RecordFailure(err *apierrors.APIError) { c.failure = err.Clone() } + // Failure returns a detached description of a failure already handled by Context. func (c *Context) Failure() *apierrors.APIError { if c.failure == nil { return nil } - copy := *c.failure - return © + return c.failure.Clone() } diff --git a/middleware/idempotency.go b/middleware/idempotency.go index a65c2fc..65b5415 100644 --- a/middleware/idempotency.go +++ b/middleware/idempotency.go @@ -32,6 +32,7 @@ type IdempotencyConfig struct { type replay struct { fingerprint [32]byte errorCode string + failure *apierrors.Snapshot status int headers http.Header body []byte @@ -151,11 +152,13 @@ func (i *Idempotency) Middleware() httpx.Middleware { return apierrors.New(409, "IDEMPOTENCY_UNAVAILABLE", "request is in progress or its outcome cannot be replayed") } status, headers, data := saved.status, saved.headers.Clone(), append([]byte(nil), saved.body...) - errorCode := saved.errorCode + errorCode, failure := saved.errorCode, saved.failure i.mu.Unlock() headers.Set("Idempotency-Replayed", "true") - if errorCode != "" { - c.RecordError(apierrors.New(status, errorCode, "")) + if failure != nil { + c.RecordFailure(failure.ErrorValue()) + } else if errorCode != "" { + c.RecordFailure(apierrors.New(status, errorCode, "")) } return c.WriteResponse(status, headers, data) } @@ -176,8 +179,12 @@ func (i *Idempotency) Middleware() httpx.Middleware { saved.failed = true saved.expires = i.now().Add(i.config.TTL) size := buffer.body.Len() - if failure := c.Failure(); failure != nil { - size += len(failure.Code) + failure := c.Failure().Snapshot() + var metadataErr error + if failure != nil { + var metadata []byte + metadata, metadataErr = json.Marshal(failure) + size += len(metadata) + len(failure.Code) } for k, values := range buffer.headers { size += len(k) @@ -185,11 +192,12 @@ func (i *Idempotency) Middleware() httpx.Middleware { size += len(v) } } - if buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes { + if metadataErr == nil && buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes { saved.failed = false saved.status = buffer.status if failure := c.Failure(); failure != nil { saved.errorCode = failure.Code + saved.failure = failure.Snapshot() } saved.headers = buffer.headers.Clone() saved.body = append([]byte(nil), buffer.body.Bytes()...) @@ -214,7 +222,7 @@ func (i *Idempotency) Middleware() httpx.Middleware { buffer.status = 200 } if failure := child.Failure(); failure != nil { - c.RecordError(failure) + c.RecordFailure(failure) } buffer.complete = true return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes()) diff --git a/middleware/middleware.go b/middleware/middleware.go index 79510ea..79641fc 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -1,6 +1,7 @@ package middleware import ( + "errors" "fmt" "runtime/debug" "time" @@ -93,6 +94,10 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware { } if failure != nil { attrs = append(attrs, "failed", true, "errorKey", failure.Code) + var parameterErr *apierrors.ParamError + if errors.As(failure, ¶meterErr) { + attrs = append(attrs, "errorParameter", parameterErr.Error()) + } if failure.BusinessCode != nil { attrs = append(attrs, "businessCode", failure.BusinessCode) } diff --git a/middleware/redis/errorParams_test.go b/middleware/redis/errorParams_test.go new file mode 100644 index 0000000..cbcc259 --- /dev/null +++ b/middleware/redis/errorParams_test.go @@ -0,0 +1,81 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "math" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + 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/middleware" + "git.yuntuo.hk/k2safe/go-omni/response" + goredis "github.com/redis/go-redis/v9" +) + +func TestTemplateErrorRedisReplay(t *testing.T) { + address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR") + if address == "" { + t.Skip("requires isolated Redis") + } + client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 2, MaxRetries: -1}) + defer client.Close() + backend, err := NewIdempotencyStore(client, fmt.Sprint("template:", time.Now().UnixNano()), IdempotencyConfig{MaxEntries: 4}) + if err != nil { + t.Fatal(err) + } + defer client.Del(context.Background(), backend.keys...) + var observed []*apierrors.APIError + calls := 0 + var bodies []string + for _, template := range []string{"{label}不能超过{max}个字符", "changed: {label}/{max}"} { + cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"LIMIT": {Code: int64(math.MaxInt64), Message: template, Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int64"}}}}} + app := omni.New(omni.WithErrors(cat), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"})) + idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: backend, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}) + if err != nil { + t.Fatal(err) + } + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { err := next(c); observed = append(observed, c.Failure()); return err } + }) + app.POST("/", func(c *httpx.Context) error { + calls++ + return apierrors.ByCode("LIMIT").WithParams(map[string]any{"label": "备注", "max": int64(math.MaxInt64)}).WithCause(errors.New("privateCause")) + }, idem.Middleware()) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", nil) + r.Header.Set("Idempotency-Key", "same") + app.ServeHTTP(w, r) + app.Close() + if w.Code != 200 || !strings.Contains(w.Body.String(), "备注不能超过9223372036854775807个字符") || !strings.Contains(w.Body.String(), `"code":9223372036854775807`) || !strings.Contains(w.Body.String(), `"data":null`) || strings.Contains(w.Body.String(), "privateCause") { + t.Fatal(w.Code, w.Body.String()) + } + bodies = append(bodies, w.Body.String()) + } + if calls != 1 || bodies[0] != bodies[1] || len(observed) != 2 { + t.Fatal(calls, bodies, observed) + } + for _, e := range observed { + if e == nil || e.BusinessCode != int64(math.MaxInt64) || e.Message != "备注不能超过9223372036854775807个字符" { + t.Fatal(e) + } + } + if observed[1].Cause != nil { + t.Fatal("persisted internal cause") + } + records, err := client.HVals(context.Background(), backend.keys[0]).Result() + if err != nil { + t.Fatal(err) + } + for _, raw := range records { + if strings.Contains(raw, "privateCause") || strings.Contains(raw, `"params"`) { + t.Fatal("private replay metadata") + } + } +} diff --git a/middleware/sharedIdempotency.go b/middleware/sharedIdempotency.go index 5b8e66d..45db54e 100644 --- a/middleware/sharedIdempotency.go +++ b/middleware/sharedIdempotency.go @@ -4,20 +4,23 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" - apierrors "git.yuntuo.hk/k2safe/go-omni/errors" - "git.yuntuo.hk/k2safe/go-omni/httpx" - "git.yuntuo.hk/k2safe/go-omni/logx" "net/http" "strings" "time" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/logx" ) type StoredResponse struct { - Status int `json:"status"` - ErrorCode string `json:"errorCode,omitempty"` - Headers http.Header `json:"headers"` - Body []byte `json:"body"` + Status int `json:"status"` + ErrorCode string `json:"errorCode,omitempty"` + Failure *apierrors.Snapshot `json:"failure,omitempty"` + Headers http.Header `json:"headers"` + Body []byte `json:"body"` } type IdempotencyRecord struct { Fingerprint string `json:"fingerprint"` @@ -55,8 +58,10 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri response := record.Response headers := response.Headers.Clone() headers.Set("Idempotency-Replayed", "true") - if response.ErrorCode != "" { - c.RecordError(apierrors.New(response.Status, response.ErrorCode, "")) + if response.Failure != nil { + c.RecordFailure(response.Failure.ErrorValue()) + } else if response.ErrorCode != "" { + c.RecordFailure(apierrors.New(response.Status, response.ErrorCode, "")) } return c.WriteResponse(response.Status, headers, response.Body) } @@ -89,13 +94,20 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri buffer.status = 200 } if failure := child.Failure(); failure != nil { - c.RecordError(failure) + c.RecordFailure(failure) } var response *StoredResponse if buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") { response = &StoredResponse{Status: buffer.status, Headers: buffer.headers.Clone(), Body: append([]byte(nil), buffer.body.Bytes()...)} if failure := child.Failure(); failure != nil { response.ErrorCode = failure.Code + response.Failure = failure.Snapshot() + } + } + if response != nil && response.Failure != nil { + metadata, encodeErr := json.Marshal(response.Failure) + if encodeErr != nil || len(metadata) > i.config.MaxResponseBytes { + response = nil } } if err = finish(response); err != nil { diff --git a/performance/errorParamsAcceptance.md b/performance/errorParamsAcceptance.md new file mode 100644 index 0000000..102985b --- /dev/null +++ b/performance/errorParamsAcceptance.md @@ -0,0 +1,27 @@ +# v0.11.0-preview 提示模板参数验收 + +## 范围与环境 + +通用错误目录增加模板参数及生成能力;字段名称、限制值、业务码、提示和 HTTP 状态均由项目提供。本轮没有修改 eTax/ePay 业务代码,没有访问业务服务器。 + +环境为 go1.27.1 darwin/arm64,验收日期 2026-09-24。网络消费者使用本地测试请求;Redis 使用独立本地临时容器,1 CPU、128 MiB、64 PID,只绑定回环地址,关闭持久化,完成后删除。 + +## 本地验收 + +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过,包含真实生成并编译的项目。 +- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 通过。 +- 模板专项覆盖:企业名称/100 与备注/500、缺参/多参/类型错误、整型范围/精度、并发隔离、修改原 map/目录副本、重复解析、WithCause 错误链、转义及非递归替换。 +- 目录加载覆盖:重复/非法参数名、Go 关键字、不支持类型、未声明/未使用占位符、非法花括号、空 params、错误 example、同标识旧 messages 冲突。旧固定提示的字面花括号保持不变。 +- JSON/YAML 配置保留 int64 示例与运行时参数精度;固定错误函数兼容。 +- HTTP 测试覆盖:生成错误与运行时错误共用逻辑,HTTP 200、data:null、403 覆盖、500 兜底、cause 不进入公开响应、RawJSON 成功与失败策略;标准中间件、路由策略及 Context 缓冲克隆保留参数。 +- 访问日志保持实际 HTTP 状态、业务码及失败标记;参数诊断不包含参数值。HTTP 200 失败计入指标。原有链路回归通过。 +- 本地/共享幂等重放不重复执行业务,保存已解析公开快照;更新目录后重放仍保持原响应及错误信息。 +- 最后补充生成消费者对 OpenAPI 参数类型、具体提示示例和 200 成功/失败 schema 的校验,再执行 `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./cmd/go-omni/apigen -run '^TestErrorParams'`。 + +## 真实 Redis 验收 + +`scripts/verifyErrorParamsRedis.py` 在独立容器中执行 `middleware/redis` 全部测试,3 项通过、0 跳过。确认模板重放、目录更新后快照不变、int64 最大值业务码通过 Lua/cjson 仍保持精度、原始参数和内部 cause 不进入持久化快照。原有共享存储并发/过期回归同时通过。 + +镜像、时间及资源限制见 [Redis 记录](errorParamsRedis.json)。临时容器已删除。未进行 Linux soak、代理长测或完整业务迁移验收。 + +远程固定版本消费者验收在发布后追加;已发布标签保持不可变。 diff --git a/performance/errorParamsRedis.json b/performance/errorParamsRedis.json new file mode 100644 index 0000000..d7b3caf --- /dev/null +++ b/performance/errorParamsRedis.json @@ -0,0 +1,25 @@ +{ + "passed": true, + "image": "sha256:f5c58b2e6e6cba5589931e8822c0ced59c507e96603d370bae3913e143f57546", + "timestamp": "2026-09-24T07:00:14.532535+00:00", + "goVersion": "go version go1.27.1 darwin/arm64", + "durationSeconds": 2.35, + "passedTests": [ + "TestTemplateErrorRedisReplay", + "TestSharedRedisIntegration", + "TestExpiredTargetBeyondCleanupBatch" + ], + "skippedTests": [], + "checks": [ + "templateReplay", + "int64CodePrecisionThroughLua", + "catalogChangeKeepsSnapshot", + "noPersistedCauseOrParams", + "http200Failure", + "dataNull", + "oneBusinessExecution" + ], + "cpuLimit": 1, + "memoryMiB": 128, + "loopbackOnly": true +} diff --git a/response/errors.go b/response/errors.go index d1b071b..4045dd4 100644 --- a/response/errors.go +++ b/response/errors.go @@ -60,6 +60,9 @@ func MustWithErrors(catalog apierrors.Catalog, next Encoder) Encoder { } func (f *Formatter) ValidateErrors(catalog apierrors.Catalog) error { for key, d := range catalog.Definitions { + if _, ok := f.config.Messages[key]; ok && len(d.Params) > 0 { + return fmt.Errorf("response: templated error %s must define its message only in the catalog", key) + } if err := f.validateError(Result{Code: key, Message: d.Message, BusinessCode: d.Code, Failed: true}); err != nil { return err } diff --git a/scripts/verifyErrorParamsRedis.py b/scripts/verifyErrorParamsRedis.py new file mode 100644 index 0000000..a892d22 --- /dev/null +++ b/scripts/verifyErrorParamsRedis.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Test template replay against an isolated, bounded local Redis container.""" +import argparse +import datetime +import json +import os +from pathlib import Path +import subprocess +import time + +ROOT = Path(__file__).resolve().parents[1] + +def run(args, **kwargs): + return subprocess.run(args, check=True, capture_output=True, text=True, **kwargs).stdout.strip() + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path, default=ROOT / "performance/errorParamsRedis.json") + args = parser.parse_args() + started = time.monotonic() + container = run(["docker", "run", "--pull=never", "--rm", "-d", "--cpus", "1", "--memory", "128m", + "--pids-limit", "64", "-p", "127.0.0.1::6379", "redis:7-alpine", + "redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "64mb", + "--maxmemory-policy", "noeviction"]) + print("Started isolated Redis (1 CPU, 128 MiB); loopback only", flush=True) + try: + image = run(["docker", "inspect", "--format", "{{.Image}}", container]) + address = run(["docker", "port", container, "6379/tcp"]) + for _ in range(30): + try: + if run(["docker", "exec", container, "redis-cli", "ping"]) == "PONG": + break + except subprocess.CalledProcessError: + pass + time.sleep(0.1) + else: + raise RuntimeError("Redis did not become ready") + env = dict(os.environ, GO_OMNI_REDIS_TEST_ADDR=address) + output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=120s", + "./middleware/redis"], cwd=ROOT, env=env, timeout=150) + 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] + skipped = [e["Test"] for e in events if e.get("Action") == "skip" and "Test" in e] + assert "TestTemplateErrorRedisReplay" in passed and not skipped, (passed, skipped) + args.report.write_text(json.dumps({ + "passed": True, "image": image, "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "goVersion": run(["go", "version"]), "durationSeconds": round(time.monotonic() - started, 2), + "passedTests": passed, "skippedTests": skipped, + "checks": ["templateReplay", "int64CodePrecisionThroughLua", "catalogChangeKeepsSnapshot", + "noPersistedCauseOrParams", "http200Failure", "dataNull", "oneBusinessExecution"], + "cpuLimit": 1, "memoryMiB": 128, "loopbackOnly": True, + }, ensure_ascii=False, indent=2) + "\n") + print("PASS isolated Redis template replay and shared store regressions", flush=True) + finally: + run(["docker", "rm", "-f", container]) + print("Temporary Redis container removed", flush=True) + +if __name__ == "__main__": + main() diff --git a/scripts/verifyErrorParamsRelease.py b/scripts/verifyErrorParamsRelease.py new file mode 100644 index 0000000..366be50 --- /dev/null +++ b/scripts/verifyErrorParamsRelease.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 typed message templates, runtime parameters, HTTP 200 business failures, +OpenAPI examples, fixed errors 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/errorParamsRemote.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="goOmniErrorParamsRelease-") 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/errorParams.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 typed error generation, parameter failures and raw responses", flush=True) + output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s", + "./cmd/go-omni/apigen", "-run", "^TestErrorParamsConsumer$"], 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 == ["TestErrorParamsConsumer"], 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", "errorCatalogValidation", "generatedErrorConstructors", + "http200BusinessFailure", "openAPIConcreteExampleAndParameters", "typedParameterOrder", + "companyAndRemarkMessages", "runtimeAndGeneratedAgreement", "missingAndWrongParameterFallback", + "concurrentIsolation", "fixedErrorCompatibility", "perErrorHTTPStatus", + "causeNotExposed", "rawSuccessAndWrappedFailure", + "earlyMiddlewareCatalog", "handwrittenFilesPreserved", "generatedConsumerRaceTests", + "fixedRemoteModuleChecksum"], + "sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in + ("scripts/verifyErrorParamsRelease.py", "cmd/go-omni/apigen/errorParams_test.go", "cmd/go-omni/apigen/errorCatalog_test.go", + "examples/contracts/errorParams.api", + "cmd/go-omni/apigen/testdata/errorParams/consumer_test.go")}, + } + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(f"PASS remote error template contract {args.version}; temporary files removed", flush=True) + + +if __name__ == "__main__": + main()