Compare commits
2 Commits
v0.10.0-pr
...
v0.11.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e3f025aad | |||
| 2041e7f661 |
15
API.md
15
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
|
||||
|
||||
@@ -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,业务只选逻辑标识;对外码和提示由项目配置。
|
||||
|
||||
25
README.md
25
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)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
54
RELEASE.md
54
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) 保留。
|
||||
|
||||
186
app/errorParams_test.go
Normal file
186
app/errorParams_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
86
cmd/go-omni/apigen/errorParams_test.go
Normal file
86
cmd/go-omni/apigen/errorParams_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrorParamsConsumer(t *testing.T) { testCatalogConsumer(t, "errorParams.api", "errorParams") }
|
||||
|
||||
func TestErrorParamsDocumentAndValidation(t *testing.T) {
|
||||
path := filepath.Join(frameworkRoot(t), "examples/contracts/errorParams.api")
|
||||
source := readFile(t, path)
|
||||
spec, err := Parse(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := document(t, compiled)
|
||||
definitions := doc["x-omni-errors"].([]any)
|
||||
found := false
|
||||
for _, value := range definitions {
|
||||
e := value.(map[string]any)
|
||||
if e["name"] == "TextTooLong" {
|
||||
found = true
|
||||
params := e["params"].([]any)
|
||||
if len(params) != 2 || params[0].(map[string]any)["name"] != "label" || params[1].(map[string]any)["type"] != "int" || e["message"] != "{label}不能超过{max}个字符" {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing template definition")
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
method := "post"
|
||||
if path == "/raw" {
|
||||
method = "get"
|
||||
}
|
||||
response := operation(t, doc, path, method)["responses"].(map[string]any)["200"].(map[string]any)
|
||||
media := response["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
value := media["examples"].(map[string]any)["TextTooLong"].(map[string]any)["value"].(map[string]any)
|
||||
if value["msg"] != "企业名称不能超过100个字符" || value["code"] != float64(400002) {
|
||||
t.Fatal(value)
|
||||
}
|
||||
if _, ok := value["data"]; !ok {
|
||||
t.Fatal("data null omitted", value)
|
||||
}
|
||||
if len(media["schema"].(map[string]any)["anyOf"].([]any)) != 2 {
|
||||
t.Fatal("success schema lost")
|
||||
}
|
||||
}
|
||||
for _, change := range [][2]string{
|
||||
{`"{label}不能超过{max}个字符"`, `"{label}不能超过{missing}个字符"`},
|
||||
{`"type": "int"`, `"type": "unknown"`},
|
||||
{`"example": 100`, `"example": "100"`},
|
||||
{`"name": "max"`, `"name": "label"`},
|
||||
{`"name": "max"`, `"name": "type"`},
|
||||
{`successCode: 0`, `successCode: 0\n messages: {"TEXT_TOO_LONG":"{label}不能超过{max}个字符"}`},
|
||||
} {
|
||||
changed := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
|
||||
s, err := parseText(t, changed)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("accepted", change)
|
||||
}
|
||||
}
|
||||
// Removing examples still produces a concrete, deterministic example.
|
||||
noExamples := strings.ReplaceAll(strings.ReplaceAll(source, `, "example": "企业名称"`, ""), `, "example": 100`, "")
|
||||
s, err := parseText(t, noExamples)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(c.Document), "label不能超过1个字符") {
|
||||
t.Fatal("missing generated example")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
149
cmd/go-omni/apigen/testdata/errorParams/consumer_test.go
vendored
Normal file
149
cmd/go-omni/apigen/testdata/errorParams/consumer_test.go
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
package consumer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"example.com/errorCatalog/internal/apierrors"
|
||||
"example.com/errorCatalog/internal/handler"
|
||||
"example.com/errorCatalog/internal/servicecontext"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedTemplateErrors(t *testing.T) {
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal([]byte(handler.OpenAPIDocument), &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operation := document["paths"].(map[string]any)["/token"].(map[string]any)["post"].(map[string]any)
|
||||
media := operation["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
example := media["examples"].(map[string]any)["TextTooLong"].(map[string]any)["value"].(map[string]any)
|
||||
if example["msg"] != "企业名称不能超过100个字符" || example["code"] != float64(400002) {
|
||||
t.Fatal(example)
|
||||
}
|
||||
if len(media["schema"].(map[string]any)["anyOf"].([]any)) != 2 {
|
||||
t.Fatal("missing success/failure schema")
|
||||
}
|
||||
found := false
|
||||
for _, raw := range document["x-omni-errors"].([]any) {
|
||||
entry := raw.(map[string]any)
|
||||
if entry["name"] == "TextTooLong" {
|
||||
found = true
|
||||
params := entry["params"].([]any)
|
||||
if len(params) != 2 || params[0].(map[string]any)["type"] != "string" || params[1].(map[string]any)["type"] != "int" {
|
||||
t.Fatal(params)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing template metadata")
|
||||
}
|
||||
// Assigning to a typed function also verifies declaration order and Go types.
|
||||
var typed func(string, int) *omnierrors.APIError = apierrors.TextTooLong
|
||||
private := errors.New("private cause")
|
||||
first := typed("企业名称", 100).WithCause(private)
|
||||
second := typed("备注", 500)
|
||||
if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" || second.Cause != nil || !errors.Is(first, private) {
|
||||
t.Fatal(first, second)
|
||||
}
|
||||
if apierrors.UsernameInvalid().Message != "用户名格式不正确" {
|
||||
t.Fatal("fixed compatibility")
|
||||
}
|
||||
cat := apierrors.Catalog()
|
||||
cat.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool"
|
||||
if typed("企业名称", 100).Message != first.Message {
|
||||
t.Fatal("mutable catalog")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 40; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
if n%2 == 0 {
|
||||
if typed("企业名称", 100).Message != first.Message {
|
||||
t.Error("isolation")
|
||||
}
|
||||
} else {
|
||||
if typed("备注", 500).Message != second.Message {
|
||||
t.Error("isolation")
|
||||
}
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
cfg := logx.Default()
|
||||
cfg.Writer = io.Discard
|
||||
logger, err := logx.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := servicecontext.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
switch c.Header("X-Fixture") {
|
||||
case "company":
|
||||
return typed("企业名称", 100).WithCause(private)
|
||||
case "remark":
|
||||
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "备注", "max": 500})
|
||||
case "missing":
|
||||
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称"})
|
||||
case "type":
|
||||
return omnierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": "企业名称", "max": "100"})
|
||||
case "fixed":
|
||||
return apierrors.UsernameInvalid()
|
||||
case "denied":
|
||||
return apierrors.ResourceDenied("资源")
|
||||
default:
|
||||
return c.OK(map[string]string{"token": "ok"})
|
||||
}
|
||||
}
|
||||
})
|
||||
if err := handler.RegisterRoutes(app, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{"company", 200, first.Message}, {"remark", 200, second.Message}, {"missing", 500, "internal server error"}, {"type", 500, "internal server error"}, {"fixed", 200, "用户名格式不正确"}, {"denied", 403, "资源不可用"}, {"", 200, `"token":"ok"`},
|
||||
} {
|
||||
method := "POST"
|
||||
if path == "/raw" {
|
||||
method = "GET"
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("X-Fixture", test.kind)
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.want) || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "{label}") {
|
||||
t.Fatal(test.kind, w.Code, w.Body.String())
|
||||
}
|
||||
if test.kind == "company" || test.kind == "remark" {
|
||||
if !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), `"data":null`) {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
if path == "/raw" && test.kind == "" && strings.Contains(w.Body.String(), "code") {
|
||||
t.Fatal("raw response wrapped")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
44
config/errorParams_test.go
Normal file
44
config/errorParams_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
272
errors/params.go
Normal file
272
errors/params.go
Normal file
@@ -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))
|
||||
}
|
||||
160
errors/params_test.go
Normal file
160
errors/params_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
71
errors/snapshot.go
Normal file
71
errors/snapshot.go
Normal file
@@ -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}
|
||||
}
|
||||
48
examples/contracts/errorParams.api
Normal file
48
examples/contracts/errorParams.api
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
81
middleware/redis/errorParams_test.go
Normal file
81
middleware/redis/errorParams_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,4 +29,14 @@
|
||||
|
||||
共享幂等新增用例使用 JSON 序列化的内存替身,验证字段在持久化边界上保留;未为本特性运行外部 Redis 服务。没有进行 Linux soak、代理长测或完整业务迁移验收。业务项目仍需升级框架/CLI,并逐步将散落错误改为目录调用。
|
||||
|
||||
远程固定版本验收在发布后追加,已发布标签保持不可变。
|
||||
## 远程固定版本验收
|
||||
|
||||
发布后两条独立远程验收均通过;各自从全新 module cache 安装远程 CLI,生成的消费者依赖固定版本,没有本地 replace。
|
||||
|
||||
- 发布标签:`v0.10.0-preview`;源码:`ebfd279f9ada059bcb5d229e463759122b8a3669`。
|
||||
- module 校验和:`h1:bbBDhMtp6ax+krphTGu9M1xnHGFORSg/ahFxWbEILyQ=`,两条验收结果一致。
|
||||
- 错误目录远程消费者:实际生成代码编译、HTTP 200 失败、同码不同提示、状态覆盖、未知标识兜底、cause 隔离、raw 输出、中间件提前返回、重生成保护与 race 测试通过;耗时 14.15 秒,详见 [错误目录远程记录](errorCatalogRemote.json)。
|
||||
- 迁移兼容性消费者:标量转换、int64 精度、动态 JSON、原始请求体验签、路由体积限制、上传临时文件清理、下载及重生成保护通过;耗时 14.48 秒,详见 [兼容性远程记录](errorCatalogCompatibilityRemote.json)。
|
||||
- 验收 fixture 哈希与当前文件一致;临时目录及缓存已清理。
|
||||
|
||||
结果在 main 单独追加,已发布标签保持不可变。
|
||||
|
||||
31
performance/errorCatalogCompatibilityRemote.json
Normal file
31
performance/errorCatalogCompatibilityRemote.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.10.0-preview",
|
||||
"frameworkSum": "h1:bbBDhMtp6ax+krphTGu9M1xnHGFORSg/ahFxWbEILyQ=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 14.48,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"scalarContractValidation",
|
||||
"manualProjectGeneration",
|
||||
"int64MaxValueOnWire",
|
||||
"dynamicJSON",
|
||||
"rawBodySignature",
|
||||
"routeBodyLimits",
|
||||
"multipartTempCleanup",
|
||||
"unwrappedDownloads",
|
||||
"handwrittenFilesPreserved",
|
||||
"generatedConsumerRaceTests",
|
||||
"fixedRemoteModuleChecksum"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"scripts/verifyCompatibilityRelease.py": "90e487d3203ed4eb4bc721da8086356413345b3e05193e473250a834aeb8d293",
|
||||
"cmd/go-omni/apigen/compatibility_test.go": "98cf5fbe6cb6413c12f709806d385726f3dec1468575f76967786de4707667ba",
|
||||
"examples/contracts/compatibility.api": "3ede909e7f60386012e55c916a8e4887dd08562e7f023665f663288c809873d4",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/consumer_test.go": "0a99ffd6a5cc16d47ae8208d9140c8d6afd9c19106fac8e5e61096cb90d8c463",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/hooks.go": "435baf37c7f8adffd18d4be0353a791124e0bc5dcb79abdcc345276b6f9867f4",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/echo.go": "9e13cb18a0951974e723a1fd915c0f4610d823c95eadb752c3d1cd75c44fa293",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/download.go": "e7f4bcdb34c90ec63fdab1f3c4e524964ac153944352d8a9053a9f19b74030fd"
|
||||
}
|
||||
}
|
||||
29
performance/errorCatalogRemote.json
Normal file
29
performance/errorCatalogRemote.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.10.0-preview",
|
||||
"frameworkSum": "h1:bbBDhMtp6ax+krphTGu9M1xnHGFORSg/ahFxWbEILyQ=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 14.15,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"errorCatalogValidation",
|
||||
"generatedErrorConstructors",
|
||||
"http200BusinessFailure",
|
||||
"duplicatePublicCodeDistinctMessages",
|
||||
"perErrorHTTPStatus",
|
||||
"unknownErrorFallback",
|
||||
"causeNotExposed",
|
||||
"rawSuccessAndWrappedFailure",
|
||||
"earlyMiddlewareCatalog",
|
||||
"handwrittenFilesPreserved",
|
||||
"generatedConsumerRaceTests",
|
||||
"fixedRemoteModuleChecksum"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"scripts/verifyErrorCatalogRelease.py": "53b00d6069296903ba8392c68dfd6d9ae6d8c83e9b82555e55e8be0ec0f2b828",
|
||||
"cmd/go-omni/apigen/errorCatalog_test.go": "9053ca2b103c32d6374ca013580b9f9f18d2319e15e445477a5c61795b32f2ae",
|
||||
"examples/contracts/errors.api": "bfbe0e81c6d0f87981f511f21d4d3e138cdf9287c5f6615856ac04a825b14435",
|
||||
"cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go": "804cee8cdbca5fe05e6340b8ac21e5bbb7d2a5c38fa6b06b121fc745aaea62ac"
|
||||
}
|
||||
}
|
||||
27
performance/errorParamsAcceptance.md
Normal file
27
performance/errorParamsAcceptance.md
Normal file
@@ -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、代理长测或完整业务迁移验收。
|
||||
|
||||
远程固定版本消费者验收在发布后追加;已发布标签保持不可变。
|
||||
25
performance/errorParamsRedis.json
Normal file
25
performance/errorParamsRedis.json
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
59
scripts/verifyErrorParamsRedis.py
Normal file
59
scripts/verifyErrorParamsRedis.py
Normal file
@@ -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()
|
||||
80
scripts/verifyErrorParamsRelease.py
Normal file
80
scripts/verifyErrorParamsRelease.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user