Compare commits

...

2 Commits

45 changed files with 2010 additions and 120 deletions

25
API.md
View File

@@ -111,6 +111,27 @@ service user-api {
## 业务错误:声明、代码和文档共用一份定义
从 `v0.10.0-preview` 起,推荐用 `errorCatalog` 同时定义逻辑标识、对外业务码、提示和 HTTP 状态:
```text
errorCatalog CommonErrors (
defaultStatus: 200
errors: {
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
"Forbidden": {"key": "FORBIDDEN", "status": 403, "code": 403001, "message": "没有权限"}
}
)
```
对象键是生成函数名;`key` 是唯一逻辑标识,允许项目自己的命名;`code` 是对外业务码,可以重复。数字码需搭配 `codeType: int32` 或 `int64` 的公共响应;字符串码搭配 `string`。目录不规定返回体字段。
省略 `defaultStatus` 时为 400;设为 200 时,只作用于目录里未单独写 `status` 的条目。生成路由自动绑定它实际声明的错误目录,在 App 中间件之前生效。业务只调用 `apierrors.UsernameInvalid()`;无需再维护 `codes` / `messages`,也无需修改 main。手写路由可复用生成的 `apierrors.Catalog()`。
同一 HTTP 200 下,OpenAPI 用 `anyOf` 保留成功 DTO 和错误体,日志与指标仍识别业务失败。完整语法、路由作用域与手写项目配置见 [统一错误目录](errors/README.md),可运行契约见 [errors.api](examples/contracts/errors.api)。
原有声明继续支持:
```go
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
@@ -121,7 +142,7 @@ error (
```
也可写单条 `error EmailTaken 409 "EMAIL_TAKEN" "email already in use"`,或把错误目录放进相对导入的 `errors.api`。
名称用 UpperCamelCase,HTTP 状态范围 400–599,错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。
名称用 UpperCamelCase,HTTP 状态允许 200–599 中可携带响应体的最终状态(排除 204/205/304),旧声明的逻辑错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。`Catalog` 为生成函数保留名称。
`@server (errors: Unauthenticated)` 声明组级错误;路由 `@errors UserNotFound, EmailTaken` 追加到组级列表并去重。
`@errors none` 清空继承的业务错误。未声明、重复声明或生成符号冲突会报告源码位置。
@@ -138,7 +159,7 @@ return nil, apierrors.EmailTaken()
OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。
默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。
`x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。
`x-omni-errors` 提供错误目录与路由关联。目录解析已定义的错误,不自动推断数据库或其他未声明错误的业务含义,也不穷举所有运行时错误。
## 鉴权:声明与路由注册一起变化

View File

@@ -1,5 +1,15 @@
# Changelog
## v0.10.0-preview
- 新增 App/路由独立错误目录:`omni.WithErrors`、`errors.Catalog`、`ByCode` 与不可变 Registry,业务只选逻辑标识;对外码和提示由项目配置。
- 默认 HTTP 状态与逐项覆盖均可配置,包括 HTTP 200 的业务失败;错误模板、RawJSON、日志、业务失败指标、链路、标准中间件桥接和幂等重放保留失败语义。
- `.api errorCatalog` 同源生成 Catalog、独立错误函数、路由目录和 OpenAPI;允许重复对外码、不同路由不同码类型、公共契约导入。旧 error 声明也允许 HTTP 200。
- 同一 200 的成功/错误响应使用 anyOf 保留两种 schema,错误示例保持各自提示;自定义 Encoder 和直接输出继续支持。
- 目录校验类型、范围、状态和冲突;JSON 配置拒绝重复键,整数保留 int64 精度;未知标识使用系统兜底,默认公开响应不暴露 cause。
- 新生成代码最低要求 v0.10.0-preview;原有构造函数、Codes/Messages 与旧生成代码继续可用。Catalog 为生成错误包保留函数名。
## v0.9.1-preview
- 新增标准 `database.OpenConnector` 和独立 `PoolConfig`;与 DSN 入口共享池参数、限时启动 Ping、失败清理、Health、事务,不注册全局驱动配置。

View File

@@ -2,7 +2,7 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始即可接入。** 当前版本为 **`v0.9.1-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
**从这份 README 开始即可接入。** 当前版本为 **`v0.10.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.9.1-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.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.9.1-preview \
go-omni api new --framework-version v0.10.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.9.1-preview \
go-omni new --api catalog.api --framework-version v0.10.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.9.1-preview \
go-omni new --api catalog.api --framework-version v0.10.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.9.1-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。
不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.10.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。
## 已有项目:按模块接入
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.9.1-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.10.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。
@@ -307,7 +307,62 @@ auth UserAuth (
生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
业务错误在 `.api` 中用 `error` 声明,通过 `@errors` 关联路由后,生成可调用的错误函数和 OpenAPI 中对应的 401、403、409 等响应。手写路由可以直接返回 `omni.NotFound`、`omni.Forbidden` 等错误。已有项目在自己的适配层转换原错误,内部原因和公开提示分开处理。
业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。
API 项目推荐在公共 `.api` 中声明目录,其他契约通过 `import` 引用:
```text
errorCatalog CommonErrors (
defaultStatus: 200
errors: {
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
"PermissionDenied": {"key": "PERMISSION_DENIED", "status": 403, "code": 403001, "message": "没有权限"}
}
)
```
`UsernameInvalid` 是生成函数名;`key` 是唯一逻辑标识;`code` 是项目对外的字符串或整数业务码,可以重复。条目没有填写 `status` 时使用目录的 `defaultStatus`,未配置目录默认值时为 400。这个默认值只作用于该目录的条目,不会把所有框架异常都改成 200。
在已有公共响应中保留 `codeType: int32` / `int64` / `string`、成功码、未定义错误的兜底码和返回体模板;不再重复填写这几条错误的 `codes` / `messages`。通过 `@server (errors: UsernameInvalid, PasswordInvalid)` 或路由 `@errors UsernameInvalid` 引用它们。
生成后业务调用:
```go
return TokenResponse{}, apierrors.UsernameInvalid()
// 需要保留内部原因时:
return TokenResponse{}, apierrors.UsernameInvalid().WithCause(err)
```
生成器同时提供 `internal/apierrors.Catalog()`、错误函数、路由错误目录及 OpenAPI。目录会在 App 中间件执行前绑定到生成路由;无需手动改已经生成的 main。HTTP 200 的错误使用失败响应模板;OpenAPI 用 `anyOf` 同时保留成功 DTO 和错误体,多条错误共用数字码时仍保留各自示例。完整契约见 [errors.api](examples/contracts/errors.api)。
手写项目可在启动时加载自己的目录,保留原来的响应配置:
```go
// omnierrors 是 git.yuntuo.hk/k2safe/go-omni/errors。
catalog := omnierrors.Catalog{
DefaultStatus: 200,
Definitions: map[string]omnierrors.Definition{
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
"PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"},
},
}
app := omni.New(
omni.WithErrors(catalog),
omni.WithResponse(response.Config{
CodeType: "int32", SuccessCode: 0, ErrorCode: 9999,
MessageField: "msg", DataMode: "always",
}),
)
// Service 只返回标准 error,不依赖 App 或 HTTP Context:
// return omnierrors.ByCode("USERNAME_INVALID").WithCause(err)
```
目录也可放入 `config.Config.Errors`,由 JSON/YAML 加载;生成项目的路径为 `server.errors`,但契约生成路由以 `.api` 绑定的目录为准。不同 App 独立持有配置,单路由也可通过 `response.WithErrors` 绑定专用目录与自定义 Encoder。直接输出文本、XML、下载或已提交的响应继续保留原协议。
访问日志保留实际 HTTP 状态,并增加失败标记、逻辑错误标识和目录业务码;指标、链路及框架标准中间件/幂等重放也保留失败语义。未知标识使用系统错误兜底,内部 cause 不写入默认公开响应。旧 `error (...)`、`New` / `BadRequest` / `Codes` / `Messages` 继续支持;新目录与旧映射冲突会报错,避免静默覆盖。
更详细的配置、作用域和升级步骤见 [统一错误目录](errors/README.md)。
### 数据库、存储及其他模块
@@ -383,7 +438,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.9.1-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.10.0-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入

View File

@@ -1,31 +1,38 @@
# v0.9.1-preview
# v0.10.0-preview
本版补齐已有项目接入的两个框架缺口:标准数据库 Connector 注入,以及应用级 HTTP 读写超时配置。TLS、数据库驱动日志、字符集/时区、迁移与业务规则继续由项目决定。
统一错误目录:由项目集中定义逻辑标识、对外业务码、公开提示和 HTTP 状态,业务只选择错误。支持 HTTP 200 的业务失败,并贯通响应、错误函数生成、日志与 OpenAPI。
## 安装与升级
## 升级
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.1-preview
# 在各业务 module 中:
go get git.yuntuo.hk/k2safe/go-omni@v0.9.1-preview
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-omni api generate --dry-run
go-omni api generate
go test ./...
```
私有仓库使用本机 Git 凭据管理器,并将 `git.yuntuo.hk` 追加到已有 `GOPRIVATE`。CLI 和项目依赖使用同一固定版本。v0.9.0-preview 生成代码继续兼容,本次无需为了这两个入口重新生成接口。
先升级项目依赖,再使用同版 CLI。新生成代码最低运行时为本版;旧的 APIError/New/BadRequest、Codes/Messages、error 声明及旧生成代码继续支持。API 错误包增加 `Catalog()`,该名称成为保留符号。项目的手写 main、Service 和 Repository 不被重生成覆盖。
## 接入变化
## 接入
- 将项目已有 `sql.OpenDB(connector)` 的池创建替换为 `database.OpenConnector(ctx, connector, poolConfig)`;不要同时保留两个池。返回对象可用于 Health/InTx;需要标准库指针时取 `.DB`。
- 项目原有 `mysql.NewConnector`、独立 TLS、driver Logger、驱动读写时限、MySQL 版本/时区/排序规则校验继续保留,配置不进入核心框架。
- 将原来逐项修改 `http.Server` 的读写/空闲/头大小配置放入 `omni.HTTPConfig`,通过 `WithHTTPConfig` 或 `WithConfig` 传入。Go-only `ErrorLog` 支持 nethttp。
- 原有 `database.Open(ctx, Config)`、手动组装 `http.Server`、自定义 Runner 均可继续使用。零 HTTP 配置保留所选网络层已有默认行为;正值覆盖,负值报错。
- 手写项目:`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。
完整可复制示例:[数据库 Connector](database/README.md#connector-接入v091-preview)、[HTTP 配置及语义](transport/README.md#http-配置v091-preview)。其他迁移能力见 [迁移扩展指南](MIGRATION.md),从项目接入开始见 [README](README.md)。
完整接入从 [README](README.md) 开始;目录/作用域/配置边界见 [错误目录](errors/README.md);可直接生成的例子见 [errors.api](examples/contracts/errors.api)。
## 边界
## 配置边界
独立实验性 `TransportHTTP2` 本次支持 IdleTimeout/MaxHeaderBytes 的配置透传,但不支持读头、读请求和写响应超时,设置这些正值会在监听前明确报错。native 与标准库服务器的超时含义不完全相同,详见上表;网络超时不等于 Handler 执行时间限制。`RunWith` 自定义 Runner 的配置由项目管理。
生成路由的目录和响应以 `.api` 为准,优先于 App 默认配置;ByCode 需要当前目录中存在该标识。使用生成函数后仍须声明实际路由的 @errors,保持文档准确。不同路由可使用不同码类型与返回格式。
Connector 必须遵守驱动 context 协议,资源所有权按标准 `database/sql` 管理。有资源的 Connector 不跨池共享;启动 Ping 失败关闭池,成功后应用负责关闭。没有把 eTax/ePay 的数据库策略写进框架,也未修改这些业务项目或宣称它们已完成迁移。
目录与响应模板分开,自定义 Encoder 继续控制最终 HTTP 状态和文本/XML 等协议。直接输出与已提交响应不会被再次包装。旧同标识 Codes/Messages 若与新目录冲突会明确拒绝。
访问日志记录实际提交状态,并带 failed/errorKey/可用的 businessCode。指标与链路识别 HTTP 200 业务失败;标准中间件和本地/共享幂等重放保留错误标识,旧缓存缺少标记时不补推历史状态。业务代码中的散落提示仍需业务项目按目录逐步迁移;本轮没有改写 eTax/ePay 业务逻辑。
## 验证入口
@@ -33,13 +40,13 @@ Connector 必须遵守驱动 context 协议,资源所有权按标准 `database
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyConnector.py --local --report performance/connectorLocal.json
python3 scripts/verifyConnector.py --version v0.9.1-preview --report performance/connectorRemote.json
python3 scripts/verifyCompatibilityRelease.py --version v0.9.1-preview --report performance/connectorCompatibilityRemote.json
python3 scripts/verifyErrorCatalogRelease.py --version v0.10.0-preview
python3 scripts/verifyCompatibilityRelease.py --version v0.10.0-preview \
--report performance/errorCatalogCompatibilityRemote.json
```
Connector 脚本只使用本地独立 MySQL 8.4 容器,限制 1 CPU、768 MiB、128 PID,生成一次性 CA/证书及凭据,结束清理自己的容器/卷/临时文件。测试验证正确 TLS、错误 CA/主机名拒绝、每池时区/日志配置隔离、事务、关闭与失败回收。HTTP 测试使用回环地址验证慢头、慢 body、空闲连接、流式写背压及请求头上限。常规单测不依赖 Docker,显式脚本要求真实 TLS 测试通过,不能跳过。
测试覆盖多 App 隔离、并发原因隔离、精确 int64、重复/非法定义拒绝、200 失败与 403 覆盖、未知错误兜底、已提交响应不追加、文本回执、访问日志、指标/链路、标准桥接、本地与序列化共享幂等存储,以及真实生成项目的 early middleware、raw 输出和重生成保护。远程脚本从全新缓存安装 CLI,检查固定版本、模块校验和与没有本地 replace。
历史发布及验收记录保留在 [CHANGELOG](CHANGELOG.md)、[v0.9.0 接入验收](performance/compatibilityAcceptance.md)。
本版结果见 [统一错误目录验收](performance/errorCatalogAcceptance.md)。
全量 race(40 个测试包)、静态检查、构建及本地隔离验收已通过,详见 [验收记录](performance/connectorAcceptance.md)。
历史 Connector/HTTP 配置验收保留在 [v0.9.1 记录](performance/connectorAcceptance.md),历史版本变化见 [CHANGELOG](CHANGELOG.md)。

View File

@@ -138,3 +138,12 @@ go run ./cmd/go-omni api generate --api examples/contracts/responses.api \
```
示例业务方法起初为 501,完成 Service 后就会套用声明的模板。参考 [完整 .api](examples/contracts/responses.api)。验收测试会在临时目录生成真实项目,写入业务方法,执行已生成路由,检查数字错误码、原始 JSON、鉴权错误、路由覆盖与重复生成保护。
## 错误目录与 HTTP 200 失败(v0.10.0-preview)
错误含义由项目的 `errors.Catalog` 或 `.api errorCatalog` 集中定义,返回字段/结构仍由本页的 response 模板控制。目录提供独立的逻辑标识、对外字符串/整数码、消息、HTTP 状态,使用 `WithErrors` 或生成路由绑定。详见 [统一错误目录](errors/README.md)。
`response.Result.IsError()` 综合明确失败标记、Err 和 HTTP 状态判断;HTTP 200 的业务错误不会使用成功码/成功提示,仍可选择 `errorBody`。新目录的公开码与提示优先于通用兜底;与同标识 Codes/Messages 冲突会拒绝,不会静默覆盖。没有目录的旧映射保持原有行为。
自定义 Encoder 可读取 `Code`(逻辑标识)、`BusinessCode`(目录对外码)、Message 与 Err,并返回项目要求的最终状态和媒体类型。不要仅通过 Status 判断成功;内部原因默认只供诊断,不应由 Encoder 直接序列化 Err。RawJSON 的成功输出保持原样,失败继续交给所选错误编码器。

View File

@@ -67,6 +67,7 @@ func configure(options ...config.Option) config.Config {
func newApp(router httpx.Router, settings config.Config) *App {
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
encoder, responseErr := response.New(settings.Response)
if responseErr != nil {
panic(responseErr)
@@ -77,6 +78,10 @@ func newApp(router httpx.Router, settings config.Config) *App {
}
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
responseEncoder, responseErr = response.WithErrors(settings.Errors, responseEncoder)
if responseErr != nil {
panic(responseErr)
}
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
if err != nil {
panic("go-omni: " + err.Error())
@@ -114,6 +119,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
func (a *App) Config() config.Config {
settings := a.config
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return settings

178
app/errorCatalog_test.go Normal file
View File

@@ -0,0 +1,178 @@
package app_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"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/logx"
"git.yuntuo.hk/k2safe/go-omni/metrics"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestAppErrorCatalogHTTP200LogsAndReplay(t *testing.T) {
var logs bytes.Buffer
logCfg := logx.Default()
logCfg.Writer = &logs
l, err := logx.New(logCfg)
if err != nil {
t.Fatal(err)
}
logx.SetDefault(l)
defer logx.SetDefault(nil)
defer l.Close()
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "项目提示"}, "DENIED": {Status: 403, Code: 9, Message: "no"}}}
policy := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}
app := omni.New(omni.WithErrors(cat), omni.WithResponse(policy), omni.WithLogger(true))
defer app.Close()
meter := metrics.New()
app.Use(meter.Middleware())
cat.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 1, Message: "mutated"}
app.Config().Errors.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 2, Message: "mutated"}
app.GET("/error", func(c *omni.Context) error {
return fmt.Errorf("wrapped: %w", apierrors.ByCode("INVALID").WithCause(errors.New("private")))
})
app.GET("/direct", func(c *omni.Context) error { c.WriteError(apierrors.ByCode("INVALID")); return nil })
app.GET("/bridge", func(c *omni.Context) error { return apierrors.ByCode("INVALID") }, httpx.WrapMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
}))
app.GET("/missing", func(c *omni.Context) error { return apierrors.ByCode("MISSING") })
app.GET("/denied", func(c *omni.Context) error { return apierrors.ByCode("DENIED") })
app.GET("/committed", func(c *omni.Context) error {
if err := c.Text(201, "done"); err != nil {
return err
}
return apierrors.ByCode("INVALID")
})
app.HandleWithResponse("GET", "/callback", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if !r.IsError() || r.BusinessCode != int64(400002) {
t.Fatal(r)
}
return response.Text(202, "ACK"), nil
}), func(c *omni.Context) error { return apierrors.ByCode("INVALID") })
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
if err != nil {
t.Fatal(err)
}
calls := 0
app.POST("/idempotent", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, idem.Middleware())
shared, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: &catalogStore{records: map[string]middleware.IdempotencyRecord{}}, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
if err != nil {
t.Fatal(err)
}
app.POST("/shared", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, shared.Middleware())
for _, path := range []string{"/error", "/direct", "/bridge", "/idempotent", "/idempotent", "/shared", "/shared"} {
method := "GET"
if path == "/idempotent" || path == "/shared" {
method = "POST"
}
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, nil)
r.Header.Set("Idempotency-Key", "same")
app.ServeHTTP(w, r)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), "项目提示") || strings.Contains(w.Body.String(), "private") {
t.Fatal(w.Code, w.Body.String())
}
}
if stats := meter.Snapshot(); stats.Errors != 7 || stats.StatusCounts["200"] != 7 {
t.Fatal("lost business failure metrics", stats)
}
if calls != 2 {
t.Fatal("replay executed business twice")
}
for _, test := range []struct {
path string
status int
body string
}{{"/denied", 403, `"code":9`}, {"/missing", 500, `"code":9999`}, {"/committed", 201, "done"}, {"/callback", 202, "ACK"}} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
if w.Code != test.status || !strings.Contains(w.Body.String(), test.body) {
t.Fatal(test.path, w.Code, w.Body.String())
}
}
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
}
if entry["failed"] != true {
t.Fatal("lost failure:", entry)
}
if entry["path"] == "/callback" {
if entry["status"] != float64(202) {
t.Fatal(entry)
}
} else if entry["path"] == "/error" || entry["path"] == "/direct" || entry["path"] == "/bridge" || entry["path"] == "/idempotent" || entry["path"] == "/shared" {
if entry["status"] != float64(200) || entry["businessCode"] != float64(400002) || entry["errorKey"] != "INVALID" {
t.Fatal(entry)
}
}
}
// A second application may interpret the same logical identifier differently.
app2 := omni.New(omni.WithErrors(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"INVALID": {Status: 409, Code: "conflict", Message: "other"}}}))
defer app2.Close()
app2.GET("/", func(*omni.Context) error { return apierrors.ByCode("INVALID") })
w := httptest.NewRecorder()
app2.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != 409 || !strings.Contains(w.Body.String(), "other") {
t.Fatal(w.Code, w.Body.String())
}
}
// A serialized store exercises the shared middleware contract, including replay
// metadata. No external service or project database is used by this fixture.
type catalogStore struct {
mu sync.Mutex
records map[string]middleware.IdempotencyRecord
}
func (s *catalogStore) Claim(_ context.Context, key, fp, owner string) (middleware.IdempotencyRecord, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.records[key]; ok {
return r, false, nil
}
r := middleware.IdempotencyRecord{Fingerprint: fp, Owner: owner, State: "running"}
s.records[key] = r
return r, true, nil
}
func (s *catalogStore) Complete(_ context.Context, key, owner string, response *middleware.StoredResponse, _ time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
r := s.records[key]
if r.Owner != owner {
return errors.New("wrong owner")
}
r.State = "failed"
if response != nil {
r.State = "completed"
encoded, err := json.Marshal(response)
if err != nil {
return err
}
var copy middleware.StoredResponse
if err := json.Unmarshal(encoded, &copy); err != nil {
return err
}
r.Response = &copy
}
s.records[key] = r
return nil
}

View File

@@ -30,6 +30,9 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
}
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
if provider, ok := a.responseEncoder.(response.ErrorRegistryProvider); ok {
requestContext.SetErrorRegistry(provider.ErrorRegistry())
}
requestContext.SetResponseEncoder(encoder)
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
if err := wrapped(requestContext); err != nil {

View File

@@ -21,6 +21,8 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api
后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。
项目内可省略 `--api` / `--dir`。`api inspect` 展示路由和鉴权/业务错误,`api export` 向 stdout 导出 OpenAPI,`api generate --dry-run` 预览文件变化。`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。支持 `error (...)`、具名 `auth`、路由 `@errors` / `@auth` 和多行 `@doc`;错误目录同步生成 `internal/apierrors` 调用函数。
`v0.10.0-preview` 新增 `errorCatalog`:集中配置业务码、提示和 HTTP 状态,支持默认 HTTP 200 与单条覆盖,生成函数、路由策略和 OpenAPI 共用一份定义。语法及配置方式见 [统一错误目录](../../errors/README.md)。
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
## 日志与对象存储按需装配
@@ -75,8 +77,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.9.1-preview
go-omni new --framework-version v0.9.1-preview --module example.com/billing ./billing
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
cd billing
go mod tidy
go run ./cmd/server

View File

@@ -8,6 +8,7 @@ import (
"sort"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"git.yuntuo.hk/k2safe/go-omni/response"
)
@@ -20,14 +21,18 @@ var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
func prepareMetadata(original *Spec) (*Spec, error) {
spec := *original
spec.Routes = append([]Route(nil), original.Routes...)
spec.Errors = append([]ErrorDefinition(nil), original.Errors...)
if err := validateResponses(&spec); err != nil {
return nil, err
}
definitions := map[string]ErrorDefinition{}
codes, symbols := map[string]bool{}, map[string]bool{}
codes, symbols := map[string]bool{}, map[string]bool{"Catalog": true}
for _, e := range spec.Errors {
if !exportedName.MatchString(e.Name) || e.Status < 400 || e.Status > 599 || !errorCode.MatchString(e.Code) || strings.TrimSpace(e.Message) == "" {
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, status 400..599, UPPER_SNAKE_CASE code and a nonempty public message", e.Position)
if !exportedName.MatchString(e.Name) || !apierrors.ValidErrorStatus(e.Status) || strings.TrimSpace(e.Message) == "" {
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, a final body-bearing status (200..599 except 204/205/304) and a nonempty public message", e.Position)
}
if e.Catalog == "" && !errorCode.MatchString(e.Code) {
return nil, fmt.Errorf("%s: legacy error declaration requires UPPER_SNAKE_CASE code", e.Position)
}
if codes[e.Code] {
return nil, fmt.Errorf("%s: duplicate error code %s", e.Position, e.Code)
@@ -41,6 +46,17 @@ func prepareMetadata(original *Spec) (*Spec, error) {
codes[e.Code] = true
definitions[e.Name] = e
}
registry, err := apierrors.NewRegistry(errorCatalog(&spec, nil))
if err != nil {
return nil, err
}
normalized := registry.Catalog()
for i := range spec.Errors {
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
}
if err := validateErrorPolicies(&spec); err != nil {
return nil, err
}
auth := map[string]AuthDefinition{}
for _, a := range spec.Auth {
if !exportedName.MatchString(a.Name) || !goName.MatchString(a.Middleware) || strings.EqualFold(a.Middleware, "defaults") || strings.EqualFold(a.Middleware, "registryGen") {
@@ -84,7 +100,7 @@ func prepareMetadata(original *Spec) (*Spec, error) {
seen := map[string]bool{}
for _, name := range r.Errors {
if _, ok := definitions[name]; !ok {
return nil, fmt.Errorf("%s: unknown error %s; declare it in error (...)", r.Position, name)
return nil, fmt.Errorf("%s: unknown error %s; declare it in errorCatalog or error (...)", r.Position, name)
}
if seen[name] {
return nil, fmt.Errorf("%s: duplicate error %s", r.Position, name)
@@ -182,11 +198,31 @@ func decorateDocument(doc map[string]any, spec *Spec) {
response = genericErrorResponse(http.StatusText(status), cfg)
responses[key] = response
}
media := response["content"].(map[string]any)["application/json"].(map[string]any)
content, ok := response["content"].(map[string]any)
if !ok {
content = map[string]any{}
response["content"] = content
}
media, hasJSON := content["application/json"].(map[string]any)
if !hasJSON {
media = map[string]any{}
content["application/json"] = media
}
errorMedia := genericErrorResponse("", cfg)["content"].(map[string]any)["application/json"].(map[string]any)
if exists && status < 400 && media["schema"] != nil {
media["schema"] = map[string]any{"anyOf": []any{media["schema"], errorMedia["schema"]}}
} else if media["schema"] == nil {
media["schema"] = errorMedia["schema"]
}
examples := map[string]any{}
codes := []any{}
seenCodes := map[string]bool{}
description := []string{}
if exists {
if text, ok := response["description"].(string); ok && text != "" {
description = append(description, text)
}
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs {
value := policy.Value(responseResult(e))
@@ -207,7 +243,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
}
}
func responseResult(e ErrorDefinition) response.Result {
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, RequestID: "exampleRequest"}
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode, Failed: true, RequestID: "exampleRequest"}
}
func genericErrorResponse(description string, cfg response.Config) map[string]any {
if description == "" {

View File

@@ -10,6 +10,8 @@ import (
// 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"`

View File

@@ -0,0 +1,118 @@
package apigen
import (
"encoding/json"
"fmt"
"sort"
"strconv"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
// A named catalog groups project definitions under one default HTTP status.
// Each JSON object key supplies the generated Go function's name, while key is
// the independent logical identifier and code is the public string/integer.
func (p *parser) parseErrorCatalog() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
if !exportedName.MatchString(name) {
return p.err("errorCatalog requires an UpperCamelCase name")
}
for _, prior := range p.spec.ErrorCatalogs {
if prior == name {
return p.err("duplicate errorCatalog " + name)
}
}
p.spec.ErrorCatalogs = append(p.spec.ErrorCatalogs, name)
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
defaultStatus := 400
if value, ok := props["defaultStatus"]; ok {
defaultStatus, err = strconv.Atoi(value)
if err != nil {
return p.err("defaultStatus must be an integer")
}
}
if !apierrors.ValidErrorStatus(defaultStatus) {
return p.err("invalid errorCatalog defaultStatus")
}
for key := range props {
if key != "defaultStatus" && key != "errors" {
return p.err("unsupported errorCatalog property " + key)
}
}
type entry struct {
Key string `json:"key"`
Code any `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
}
var entries map[string]entry
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
return fmt.Errorf("%s: catalog %s: %w", pos, name, err)
}
if len(entries) == 0 {
return p.err("errorCatalog requires errors")
}
names := make([]string, 0, len(entries))
for key := range entries {
names = append(names, key)
}
sort.Strings(names)
for _, fn := range names {
e := entries[fn]
if e.Code == nil {
return p.err("errorCatalog error requires code")
}
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})
}
return nil
}
func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{}}
selected := map[string]bool{}
for _, n := range names {
selected[n] = true
}
for _, e := range spec.Errors {
if names != nil && !selected[e.Name] {
continue
}
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message}
}
return c
}
// Validate the exact catalog/template pair used by each route. Different routes
// can use different code types without forcing a global response representation.
func validateErrorPolicies(spec *Spec) error {
for _, r := range spec.Routes {
f, err := response.New(responseConfig(spec, r))
if err != nil {
return err
}
if _, err = response.WithErrors(errorCatalog(spec, append([]string{}, r.Errors...)), f); err != nil {
return fmt.Errorf("%s: %w", r.Position, err)
}
}
return nil
}
func errorCatalogJSON(spec *Spec, names []string) string {
data, err := json.Marshal(errorCatalog(spec, names))
if err != nil {
panic(err)
}
return string(data)
}

View File

@@ -0,0 +1,205 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestErrorCatalogDocumentAndValidation(t *testing.T) {
path := filepath.Join(frameworkRoot(t), "examples/contracts/errors.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)
}
for _, path := range []string{"/token", "/raw"} {
method := "post"
if path == "/raw" {
method = "get"
}
op := operation(t, document(t, compiled), path, method)
response := op["responses"].(map[string]any)["200"].(map[string]any)
media := response["content"].(map[string]any)["application/json"].(map[string]any)
alternatives := media["schema"].(map[string]any)["anyOf"].([]any)
if len(alternatives) != 2 {
t.Fatal("lost success or failure shape")
}
if path == "/raw" {
if alternatives[0].(map[string]any)["properties"].(map[string]any)["token"] == nil {
t.Fatal("raw success wrapped")
}
}
examples := media["examples"].(map[string]any)
if len(examples) != 2 {
t.Fatal(examples)
}
for _, name := range []string{"UsernameInvalid", "PasswordInvalid"} {
value := examples[name].(map[string]any)["value"].(map[string]any)
if value["ok"] != false || value["code"] != float64(400002) || value["msg"] == nil {
t.Fatal(value)
}
}
}
for _, change := range [][2]string{
{`defaultStatus: 200`, `defaultStatus: 204`},
{`"status": 403`, `"status": 304`},
{`"PASSWORD_INVALID"`, `"USERNAME_INVALID"`},
{`"code": 400002`, `"code": 2147483648`},
{`"code": 400002`, `"code": "400002"`},
{`"code": 400002`, `"code": 1.5`},
{`"code": 400002`, `"code": null`},
{`"code": 400002`, `"code": 400002, "code": 400003`},
{`"message": "用户名格式不正确"`, `"message": "用户名格式不正确", "typo": true`},
{`@handler token`, `@errors Unknown\n @handler token`},
{`successCode: 0`, `successCode: 0\n codes: {"USERNAME_INVALID": 999}`},
} {
text := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
s, err := parseText(t, text)
if err == nil {
_, err = Compile(s)
}
if err == nil {
t.Errorf("accepted %s", change[1])
}
}
// The legacy declaration is also allowed to carry a HTTP 200 failure.
old := strings.Replace(declaredAPI, "EmailTaken 409", "EmailTaken 200", 1)
s, err := parseText(t, old)
if err == nil {
_, err = Compile(s)
}
if err != nil {
t.Fatal(err)
}
}
func TestErrorCatalogConsumer(t *testing.T) {
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}
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(check bool) {
t.Helper()
if cli == "" {
o := opts
o.Check = check
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
return
}
args := []string{"api", "generate", "--api", opts.API, "--dir", dir, "--module", opts.Module}
if check {
args = append(args, "--check")
}
cmd := exec.Command(cli, args...)
cmd.Dir = filepath.Dir(dir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("release generate %v\n%s", err, output)
}
}
generate(false)
fixture := readFile(t, "testdata/errorCatalog/consumer_test.go")
writeFile(t, filepath.Join(dir, "consumer_test.go"), fixture)
generate(false)
generate(true)
if readFile(t, filepath.Join(dir, "consumer_test.go")) != fixture {
t.Fatal("regeneration overwrote manual code")
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("consumer %v\n%s", err, output)
}
if cli != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(output))
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(output, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
}
func TestErrorCatalogImportsAndRouteScopes(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "common.api"), `errorCatalog Numeric (
defaultStatus: 200
errors: {"NumberInvalid":{"key":"number.invalid","code":12,"message":"number"}}
)
errorCatalog Text (
defaultStatus: 409
errors: {"TextInvalid":{"key":"textInvalid","code":"000012","message":"text"}}
)
response NumericResponse (
codeType: int32
successCode: 0
errorCode: 9999
)
`)
writeFile(t, filepath.Join(dir, "service.api"), `import "common.api"
@server (
response: NumericResponse
errors: NumberInvalid
)
service scopes-api {
@handler numeric
get /number
}
@server (
errors: TextInvalid
)
service scopes-api {
@handler text
get /text
}
`)
s, err := Parse(filepath.Join(dir, "service.api"))
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(s)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
for _, tc := range []struct {
path, status, name string
code any
}{{"/number", "200", "NumberInvalid", float64(12)}, {"/text", "409", "TextInvalid", "000012"}} {
response := operation(t, doc, tc.path, "get")["responses"].(map[string]any)[tc.status].(map[string]any)
value := response["content"].(map[string]any)["application/json"].(map[string]any)["examples"].(map[string]any)[tc.name].(map[string]any)["value"].(map[string]any)
if value["code"] != tc.code {
t.Fatal(value)
}
}
}

View File

@@ -19,7 +19,7 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.9.0-preview"
const minimumVersion = "v0.10.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, "contract", "scalar.go")); err == nil {
if _, err := os.Stat(filepath.Join(path, "errors", "catalog.go")); err == nil {
localCurrent = true
}
}
}
if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates migration extensions; use %s or a current local replacement", minimumVersion)
return Output{}, fmt.Errorf("existing framework dependency predates error catalog support; use %s or a current local replacement", minimumVersion)
}
}
if o.FrameworkDir != "" {
@@ -312,12 +312,14 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
generated, seeds := map[string][]byte{}, map[string][]byte{}
put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) }
if len(c.Spec.Errors) > 0 {
content := "package apierrors\nimport omnierrors " + strconv.Quote(frameworkModule+"/errors") + "\n"
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\n// %s returns a fresh public error declared in the API contract.\nfunc %s()*omnierrors.APIError{return omnierrors.New(%d,Code%s,%q)}\n", e.Name, e.Code, e.Name, e.Name, e.Status, e.Name, e.Message)
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)
}
put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content)
}
types := "package types\n\n"
if len(c.Spec.Scalars) > 0 {
types += "import("
@@ -343,7 +345,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(generated, "internal/types/typesGen.go", generatedHeader+types)
put(generated, "docs/openapi.json", string(c.Document)+"\n")
put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n"))
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response")
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q; omnierrors %q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response", frameworkModule+"/errors")
for _, definition := range c.Spec.Responses {
data, err := json.Marshal(definition.Config)
if err != nil {
@@ -351,7 +353,8 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
}
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
}
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\nfunc responseEncoder(name string,raw bool)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n"
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};encoder=response.MustWithErrors(catalog,encoder);if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext")
routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
@@ -412,9 +415,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
name := upper(r.Handler)
// The response policy and body reader cap are installed before App middleware.
if r.MaxBodyBytes > 0 {
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", i, name)
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
} else {
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", i, name)
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
}
for _, m := range r.Middleware {
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)

View File

@@ -137,7 +137,7 @@ func TestDeclaredErrorsAndAuthentication(t *testing.T) {
func TestInvalidDeclarations(t *testing.T) {
replacements := [][2]string{
{`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`},
{`EmailTaken 409`, `EmailTaken 200`},
{`EmailTaken 409`, `EmailTaken 204`},
{`EmailTaken 409`, `emailTaken 409`},
{`NameTaken 409`, `CodeEmailTaken 409`},
{`auth: UserAuth`, `auth: Unknown`},

View File

@@ -16,6 +16,7 @@ import (
type Spec struct {
Title, Version, Service, Description string
Errors []ErrorDefinition
ErrorCatalogs []string
Auth []AuthDefinition
Responses []ResponseDefinition
Scalars []ScalarDefinition
@@ -266,6 +267,10 @@ func (p *parser) parse() error {
}
p.imports = append(p.imports, v)
}
case "errorCatalog":
if err := p.parseErrorCatalog(); err != nil {
return err
}
case "error":
if err := p.parseErrors(); err != nil {
return err

View File

@@ -0,0 +1,92 @@
package consumer_test
import (
"context"
"errors"
"io"
"net/http/httptest"
"strings"
"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 TestGeneratedCatalog(t *testing.T) {
first := apierrors.UsernameInvalid().WithCause(errors.New("private"))
second := apierrors.UsernameInvalid()
if first == second || second.Cause != nil || second.Status != 200 || second.BusinessCode != int64(400002) {
t.Fatal("shared or incomplete generated error")
}
copy := apierrors.Catalog()
delete(copy.Definitions, apierrors.CodeUsernameInvalid)
if len(apierrors.Catalog().Definitions) != 3 {
t.Fatal("shared catalog")
}
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()
// Global middleware exits early: generated route policy must already be active.
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
switch c.Header("X-Fixture") {
case "username":
return omnierrors.ByCode(apierrors.CodeUsernameInvalid).WithCause(errors.New("private"))
case "password":
return apierrors.PasswordInvalid()
case "denied":
return apierrors.PermissionDenied()
case "unknown":
return omnierrors.ByCode("MISSING")
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
}{{"username", 200, "用户名格式不正确"}, {"password", 200, "密码格式不正确"}, {"denied", 403, "没有权限"}, {"unknown", 500, "internal server error"}, {"", 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") {
t.Fatal(path, test.kind, w.Code, w.Body.String())
}
if test.kind == "username" || test.kind == "password" {
if !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), `"ok":false`) {
t.Fatal(w.Body.String())
}
}
if path == "/raw" && test.kind == "" && strings.Contains(w.Body.String(), "code") {
t.Fatal("raw success wrapped")
}
}
}
}

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.9.1-preview"
const defaultFrameworkVersion = "v0.10.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.

View File

@@ -4,6 +4,7 @@ import (
"strings"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
@@ -35,6 +36,7 @@ type Config struct {
TrustedProxies []string `json:"trustedProxies"`
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
Log logx.Config `json:"log"`
Errors apierrors.Catalog `json:"errors"`
Response response.Config `json:"response"`
ResponseEncoder response.Encoder `json:"-"`
}
@@ -51,6 +53,18 @@ func Default() Config {
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.Errors.Validate(); err != nil {
return err
}
if c.ResponseEncoder == nil {
f, err := response.New(c.Response)
if err != nil {
return err
}
if err = f.ValidateErrors(c.Errors); err != nil {
return err
}
}
if err := c.Response.Validate(); err != nil {
return err
}
@@ -151,11 +165,13 @@ func WithTrustedProxies(proxies ...string) Option {
// environment. Later options can still override individual fields.
func WithConfig(settings Config) Option {
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return func(cfg *Config) {
*cfg = settings
cfg.Response = settings.Response.Clone()
cfg.Errors = settings.Errors.Clone()
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
}
@@ -190,3 +206,9 @@ func WithShutdownTimeout(timeout time.Duration) Option {
}
}
}
// WithErrors installs a project-owned, app-local catalog.
func WithErrors(catalog apierrors.Catalog) Option {
catalog = catalog.Clone()
return func(cfg *Config) { cfg.Errors = catalog.Clone() }
}

View File

@@ -19,6 +19,7 @@ func LoadFromEnv(prefix string) (Config, error) {
// ApplyEnv overlays nonempty environment values on a caller's defaults or file
// settings, then validates the result. Caller-owned slices are not retained.
func ApplyEnv(defaults Config, prefix string) (Config, error) {
defaults.Errors = defaults.Errors.Clone()
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
return applyEnv(defaults, prefix)

View File

@@ -0,0 +1,75 @@
package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/config"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestCatalogConfigFiles(t *testing.T) {
for _, file := range []struct{ name, body string }{
{"server.json", `{"response":{"codeType":"int64","successCode":0,"errorCode":9999},"errors":{"defaultStatus":200,"definitions":{"LIMIT":{"code":9223372036854775807,"message":"limit"}}}}`},
{"server.yaml", "response:\n codeType: int64\n successCode: 0\n errorCode: 9999\nerrors:\n defaultStatus: 200\n definitions:\n LIMIT:\n code: 9223372036854775807\n message: limit\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, "CATALOG_FIXTURE_")
if err != nil {
t.Fatal(err)
}
e := apierrors.MustRegistry(cfg.Errors).Resolve(apierrors.ByCode("LIMIT"))
if e.Status != 200 || e.BusinessCode != int64(9223372036854775807) {
t.Fatal(e)
}
})
}
for _, body := range []string{
`{"errors":{"definitions":{"A":{"message":"a","code":1},"A":{"message":"b","code":2}}}}`,
`{"response":{"codeType":"int32","successCode":0,"errorCode":99},"errors":{"definitions":{"A":{"message":"a","code":2147483648}}}}`,
`{"errors":{"definitions":{"A":{"message":"a","code":"a","status":204}}}}`,
} {
path := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatal(err)
}
if _, err := config.LoadFile(path, "CATALOG_FIXTURE_"); err == nil {
t.Fatal("accepted invalid catalog", body)
}
}
}
func TestCatalogReloadSnapshotIsolation(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := `{"errors":{"defaultStatus":200,"definitions":{"invalid":{"code":"invalid","message":"original"}}}}`
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
t.Fatal(err)
}
logger, err := logx.New(logx.Default())
if err != nil {
t.Fatal(err)
}
defer logger.Close()
reloader, err := config.NewReloader(path, "CATALOG_RELOAD_", logger)
if err != nil {
t.Fatal(err)
}
snapshot := reloader.Snapshot()
snapshot.Errors.Definitions["invalid"] = apierrors.Definition{Message: "changed"}
if reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" {
t.Fatal("snapshot changed live catalog")
}
if err := os.WriteFile(path, []byte(strings.Replace(original, "original", "new message", 1)), 0600); err != nil {
t.Fatal(err)
}
if err := reloader.Reload(); err == nil || reloader.Snapshot().Errors.Definitions["invalid"].Message != "original" {
t.Fatal("catalog changed without restart", err)
}
}

View File

@@ -5,8 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gopkg.in/yaml.v3"
"io"
"os"
"path/filepath"
@@ -14,6 +12,10 @@ import (
"strings"
"sync"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"gopkg.in/yaml.v3"
)
// DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON
@@ -45,15 +47,9 @@ func DecodeFile[T any](path string, defaults T) (T, error) {
return zero, errors.New("config: one YAML document required")
}
case ".json":
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&value); err != nil {
if err := apierrors.DecodeCatalogJSON(data, &value); err != nil {
return zero, err
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return zero, errors.New("config: one JSON document required")
}
default:
return zero, errors.New("config: expected .json, .yaml or .yml")
}
@@ -182,6 +178,7 @@ func (r *Reloader) Snapshot() Config {
defer r.mu.Unlock()
cfg := r.current
cfg.Response = r.current.Response.Clone()
cfg.Errors = r.current.Errors.Clone()
cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...)
return cfg

133
errors/README.md Normal file
View File

@@ -0,0 +1,133 @@
# 统一错误目录
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
## 基本规则
- `key` / `Definitions` 的 map key 是唯一逻辑标识,例如 `USERNAME_INVALID`、`user.notFound`。API 新目录允许项目自己的标识命名;旧 `error (...)` 声明保持原有大写标识规则。
- `code` 是对外业务码,支持字符串、整数,同一个码可以由多个不同标识使用。JSON/YAML 和 `.api` 读取整数时保留 int64 精度。
- `DefaultStatus` / `defaultStatus` 只为目录中未写状态的条目提供默认值,省略时为 400。项目可设为 200;单项 `Status` / `status` 优先。
- 目录条目允许有响应体的最终 HTTP 状态:200..599,排除 204、205、304。成功输出与错误输出分开;HTTP 200 并不把错误变成成功。
- 数字码类型与范围遵守选中响应的 `codeType`(string/int32/int64;integer 是旧别名)。不同路由可以选择不同类型的公共响应与目录。
- 新目录声明完整公开提示;每条错误的业务码和提示优先于统一 `errorCode` / `errorMessage` 兜底。旧的同标识 `codes` / `messages` 若与目录冲突,初始化或契约编译报错;相同值允许保留,便于逐步迁移。
## 手写项目
```go
import (
omni "git.yuntuo.hk/k2safe/go-omni"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
catalog := omnierrors.Catalog{
DefaultStatus: 200,
Definitions: map[string]omnierrors.Definition{
"USERNAME_INVALID": {Code: 400002, Message: "用户名格式不正确"},
"PASSWORD_INVALID": {Code: 400002, Message: "密码格式不正确"},
"FORBIDDEN": {Status: 403, Code: 403001, Message: "没有权限"},
},
}
app := omni.New(
omni.WithErrors(catalog),
omni.WithResponse(response.Config{
CodeType: "int32", SuccessCode: 0, ErrorCode: 9999,
MessageField: "msg", DataMode: "always",
}),
)
```
Service 返回 `omnierrors.ByCode("USERNAME_INVALID")`,或 `.WithCause(err)`。业务无需接收 App/HTTP Context。目录属于 App,初始化时校验并复制;后续修改原 map 不影响正在服务的应用。配置快照也不暴露内部 map。
`NewRegistry(catalog)` / `MustRegistry(catalog)` 可在 CLI/任务消费者等非 HTTP 入口使用;`registry.Resolve(err)` 返回独立的 `*APIError`,包含逻辑 `Code`、对外 `BusinessCode`、Status、Message 与内部 Cause。每次调用都创建新对象;不要自行将一个可变错误对象保存为共享单例再调用 WithCause。
原有 `New(status, key, message)` 等明确错误也能被同标识目录条目覆盖;未在目录中的明确错误沿用原状态与提示。未配置目录时,原有行为保持。Go 目录允许 `Definition.Code == nil` 以继续使用旧 Codes/errorCode 映射;新 `.api errorCatalog` 要求显式 code。
## 文件配置
下面是生成项目的 `etc/config.yaml` 结构;直接 `config.LoadFile` 加载框架配置时去掉外面的 `server`。手写 main 需显式将加载结果传入 `WithConfig`。
```yaml
server:
errors:
defaultStatus: 200
definitions:
USERNAME_INVALID:
code: 400002
message: 用户名格式不正确
FORBIDDEN:
status: 403
code: 403001
message: 没有权限
response:
codeType: int32
successCode: 0
errorCode: 9999
messageField: msg
dataMode: always
```
重复 JSON/YAML key、未知字段、非法状态、非整数数值及类型/范围错误会拒绝加载。目录不支持运行时热更新;修改后重新启动,避免请求与文档在运行期间漂移。
## .api:定义一次,生成调用与文档
完整示例见 [errors.api](../examples/contracts/errors.api)。语法:
```text
errorCatalog CommonErrors (
defaultStatus: 200
errors: {
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"}
}
)
```
目录可放进公共 `.api` 再 import;也可以拆成多个命名目录,各自设置默认状态。生成器合并定义时检查目录名、函数名、逻辑标识冲突。`Catalog` 是生成包的保留函数名。目录对象的键是生成的 UpperCamelCase 函数名,条目的 `key` 是运行时逻辑标识,二者用途不同。
在 `@server` 的 `errors` 或路由 `@errors` 中引用函数名,响应仍通过原有 `response` 声明选择:
```text
@server (
response: CommonResponse
errors: UsernameInvalid, PasswordInvalid
)
service account-api {
@handler token
post /token returns (TokenResponse)
}
```
生成结果包括:
- `apierrors.UsernameInvalid()` 等无参数函数,每次返回独立错误,支持 WithCause。
- `apierrors.CodeUsernameInvalid` 等逻辑标识常量。
- `apierrors.Catalog()`:整个契约的可复制运行时目录,方便手写路由复用。
- 自动绑定到生成路由的目录:只包含该路由通过 `@errors` / `@server errors` 声明的错误,在 App 全局中间件之前生效,支持认证提前返回和缓冲克隆。
- OpenAPI 的错误响应与示例。同一 HTTP 200 下用 `anyOf` 同时描述成功 DTO 和错误体;多个错误共用数字码时保持多个示例;raw 成功体与失败公共体也分开描述。
业务直接 `return TokenResponse{}, apierrors.UsernameInvalid()`。也可以返回 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`;使用标识引用时,当前 App/路由目录必须包含该错误。不要把“函数已生成”视作“所有路由都已声明这个错误”:新增实际错误后仍应补相应 `@errors`,保持文档准确。
生成路由的目录和响应由 `.api` 管理;YAML 或 App 默认策略不覆盖它。手写路由可在 main 用 `omni.WithErrors(apierrors.Catalog())` 并选择兼容的 `WithResponse`。新项目和重复生成都会自动绑定路由目录,无需手动修改 main;已有手写文件保持不动。
## 特殊协议与失败信息
`response.WithErrors(catalog, encoder)` / `MustWithErrors` 可绑定路由专用策略,再通过 `HandleWithResponse` 注册。内置模板校验目录码类型与旧映射;自定义 Encoder 自行决定最终 HTTP 状态、媒体类型和字段结构,可将失败转换成第三方要求的文本/XML 回执。一个 Encoder 作用域不叠加不同目录;外层空目录或相同目录不会覆盖已有目录。
Encoder 的 `response.Result` 包含逻辑 `Code`、对外 `BusinessCode`、公开 `Message`、原始 `Err` 及 `Failed`。使用 `result.IsError()` 判断失败,不要仅判断 `Status >= 400`。`RawJSON` 仅让成功结果保持未包装,HTTP 200 的失败仍使用选定错误策略。`c.Text` / `c.XML` / `c.JSON` 等直接输出及已提交的标准 Handler 响应不会被目录重新包装。
启用访问日志时,记录最终写出的 HTTP 状态,并保留 `failed`、`errorKey` 和可用的 `businessCode`。内置指标将已处理的业务失败计入 Errors;链路记录保留 HTTP 200 和业务失败标记。标准中间件桥接和本地/共享幂等重放保留失败标识;已有缓存条目没有新增元数据时无法追溯识别业务失败,等待正常过期即可。
未知的 ByCode 标识、普通 error、panic 默认走 `INTERNAL_ERROR`,公开兜底不暴露 cause。项目可显式配置 INTERNAL_ERROR 条目,但目录 DefaultStatus 本身不会改变系统兜底。启用访问日志后,可通过原始 error 追查未知标识和内部原因。
## 升级
```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-omni api generate --dry-run
go-omni api generate
go test ./...
```
先升级框架依赖,再用同版 CLI 生成;新生成代码最低依赖 v0.10.0-preview。原来的错误构造函数、旧 `error (...)`、Codes/Messages 与已生成代码可继续运行。把业务中散落的状态/提示逐步替换为生成函数属于项目迁移工作,框架不会自动改写业务逻辑。

217
errors/catalog.go Normal file
View File

@@ -0,0 +1,217 @@
package apierrors
import (
"bytes"
"encoding/json"
stdErrors "errors"
"fmt"
"io"
"math"
"reflect"
"strings"
)
// Definition belongs to the project. Code is the public string/integer code;
// 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"`
}
type Catalog struct {
DefaultStatus int `json:"defaultStatus,omitempty"`
Definitions map[string]Definition `json:"definitions"`
}
// UnmarshalJSON preserves int64 precision and rejects ambiguous duplicate keys.
func (c *Catalog) UnmarshalJSON(data []byte) error {
type plain Catalog
value := plain(c.Clone())
if err := DecodeCatalogJSON(data, &value); err != nil {
return err
}
*c = Catalog(value)
return nil
}
// DecodeCatalogJSON strictly decodes catalog metadata without float conversion.
// It is also used by the API compiler so runtime and contracts agree.
func DecodeCatalogJSON(data []byte, dst any) error {
scan := json.NewDecoder(bytes.NewReader(data))
scan.UseNumber()
var read func() error
read = func() error {
token, err := scan.Token()
if err != nil {
return err
}
if d, ok := token.(json.Delim); ok {
switch d {
case '{':
seen := map[string]bool{}
for scan.More() {
k, err := scan.Token()
if err != nil {
return err
}
key, ok := k.(string)
if !ok {
return fmt.Errorf("errors: object key required")
}
if seen[key] {
return fmt.Errorf("errors: duplicate key %q", key)
}
seen[key] = true
if err := read(); err != nil {
return err
}
}
case '[':
for scan.More() {
if err := read(); err != nil {
return err
}
}
default:
return fmt.Errorf("errors: invalid JSON delimiter")
}
_, err = scan.Token()
return err
}
return nil
}
if err := read(); err != nil {
return err
}
if _, err := scan.Token(); err != io.EOF {
return fmt.Errorf("errors: one JSON value required")
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func (c Catalog) Clone() Catalog {
out := c
out.Definitions = make(map[string]Definition, len(c.Definitions))
for key, d := range c.Definitions {
out.Definitions[key] = d
}
return out
}
// 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 }
func NewRegistry(c Catalog) (*Registry, error) {
c = c.Clone()
if c.DefaultStatus == 0 {
c.DefaultStatus = 400
}
if !ValidErrorStatus(c.DefaultStatus) {
return nil, fmt.Errorf("errors: invalid defaultStatus %d", c.DefaultStatus)
}
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)
}
if d.Status == 0 {
d.Status = c.DefaultStatus
}
if !ValidErrorStatus(d.Status) {
return nil, fmt.Errorf("errors: %s has invalid status %d", key, d.Status)
}
if strings.TrimSpace(d.Message) == "" {
return nil, fmt.Errorf("errors: %s requires a public message", key)
}
if d.Code != nil {
code, err := normalizeCatalogCode(d.Code)
if err != nil {
return nil, fmt.Errorf("errors: %s code: %w", key, err)
}
d.Code = code
}
c.Definitions[key] = d
}
return &Registry{catalog: c}, nil
}
// ValidErrorStatus permits final responses with a JSON body, including HTTP 200.
func ValidErrorStatus(status int) bool {
return status >= 200 && status <= 599 && status != 204 && status != 205 && status != 304
}
func MustRegistry(c Catalog) *Registry {
r, err := NewRegistry(c)
if err != nil {
panic(err)
}
return r
}
func (c Catalog) Validate() error { _, err := NewRegistry(c); return err }
func (r *Registry) Catalog() Catalog {
if r == nil {
return Catalog{}
}
return r.catalog.Clone()
}
// ByCode identifies an error without coupling business code to its HTTP policy.
// Resolution occurs in the application's/request's registry. An unknown reference
// becomes an internal error; its identifier and cause stay available to logs.
func ByCode(key string) *APIError { return &APIError{Code: key, reference: true} }
func (r *Registry) Resolve(err error) *APIError {
if err == nil {
return nil
}
var source *APIError
if !stdErrors.As(err, &source) || source == nil {
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: source.Cause}
}
}
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}
}
}
}
copy := *source
return &copy
}
func normalizeCatalogCode(value any) (any, error) {
if v, ok := value.(string); ok {
if v == "" {
return nil, fmt.Errorf("must be nonempty")
}
return v, nil
}
if v, ok := value.(json.Number); ok {
n, err := v.Int64()
if err != nil {
return nil, fmt.Errorf("must be an exact int64")
}
return n, nil
}
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int(), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if v.Uint() <= math.MaxInt64 {
return int64(v.Uint()), nil
}
}
return nil, fmt.Errorf("must be a string or signed 64-bit integer")
}

100
errors/catalog_test.go Normal file
View File

@@ -0,0 +1,100 @@
package apierrors_test
import (
"encoding/json"
"errors"
"fmt"
"math"
"sync"
"testing"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
func TestCatalogIsolationDefaultsAndCauses(t *testing.T) {
catalog := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
"FIRST": {Code: 400002, Message: "first"}, "SECOND": {Code: 400002, Message: "second"}, "DENIED": {Status: 403, Code: "denied", Message: "denied"},
}}
r, err := apierrors.NewRegistry(catalog)
if err != nil {
t.Fatal(err)
}
catalog.Definitions["FIRST"] = apierrors.Definition{Message: "changed"}
r.Catalog().Definitions["FIRST"] = apierrors.Definition{Message: "also changed"}
cause := errors.New("private secret")
var wg sync.WaitGroup
for i := 0; i < 40; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ref := apierrors.ByCode("FIRST").WithCause(cause)
e := r.Resolve(fmt.Errorf("wrapped: %w", ref))
if e.Status != 200 || e.BusinessCode != int64(400002) || e.Message != "first" || !errors.Is(e, cause) {
t.Error(e)
}
e.WithCause(errors.New("different"))
if ref.Cause != cause || r.Resolve(apierrors.ByCode("FIRST")).Cause != nil {
t.Error("shared state")
}
}()
}
wg.Wait()
if got := r.Resolve(apierrors.ByCode("SECOND")); got.Message != "second" || got.BusinessCode != int64(400002) {
t.Fatal(got)
}
if got := r.Resolve(apierrors.ByCode("DENIED")); got.Status != 403 || got.BusinessCode != "denied" {
t.Fatal(got)
}
other := apierrors.MustRegistry(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"FIRST": {Status: 409, Code: 9, Message: "other"}}})
if got := other.Resolve(apierrors.ByCode("FIRST")); got.Status != 409 || got.Message != "other" {
t.Fatal(got)
}
if got := r.Resolve(apierrors.ByCode("MISSING").WithCause(cause)); got.Status != 500 || got.Code != "INTERNAL_ERROR" || got.Message == cause.Error() || !errors.Is(got, cause) {
t.Fatal(got)
}
if got := apierrors.From(apierrors.ByCode("MISSING")); got.Status != 500 {
t.Fatal(got)
}
legacy := apierrors.New(422, "OLD", "legacy")
if got := r.Resolve(legacy); got == legacy || got.Status != 422 || got.Message != "legacy" {
t.Fatal(got)
}
}
func TestCatalogStrictJSONAndValues(t *testing.T) {
raw := `{"defaultStatus":200,"definitions":{"LARGE":{"code":9223372036854775807,"message":"large"}}}`
var c apierrors.Catalog
if err := json.Unmarshal([]byte(raw), &c); err != nil {
t.Fatal(err)
}
e := apierrors.MustRegistry(c).Resolve(apierrors.ByCode("LARGE"))
if e.BusinessCode != int64(math.MaxInt64) {
t.Fatal(e.BusinessCode)
}
for _, raw := range []string{
`{"definitions":{"A":{"code":1,"message":"a"},"A":{"code":2,"message":"b"}}}`,
`{"definitions":{"A":{"code":1,"code":2,"message":"a"}}}`,
`{"definitions":{"A":{"code":1,"message":"a","typo":true}}}`,
`{"defaultStatus":204,"definitions":{}}`,
`{"definitions":{"A":{"code":1.5,"message":"a"}}}`,
`{"definitions":{"A":{"code":9223372036854775808,"message":"a"}}}`,
} {
var invalid apierrors.Catalog
err := json.Unmarshal([]byte(raw), &invalid)
if err == nil {
err = invalid.Validate()
}
if err == nil {
t.Errorf("accepted %s", raw)
}
}
for _, status := range []int{0, 99, 199, 204, 205, 304, 600} {
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"A": {Status: status, Code: "a", Message: "a"}}}
if status == 0 {
continue
}
if c.Validate() == nil {
t.Fatal(status)
}
}
}

View File

@@ -12,6 +12,8 @@ type APIError struct {
Code string
Message string
Cause error
BusinessCode any // Optional public code resolved from a project catalog.
reference bool
}
func (e *APIError) Error() string {
@@ -68,6 +70,9 @@ func From(err error) *APIError {
}
var apiErr *APIError
if stdErrors.As(err, &apiErr) && apiErr != nil {
if apiErr.reference {
return Internal(err)
}
return apiErr
}
return Internal(err)

View File

@@ -0,0 +1,36 @@
syntax = "v1"
errorCatalog CommonErrors (
defaultStatus: 200
errors: {
"UsernameInvalid": {"key": "USERNAME_INVALID", "code": 400002, "message": "用户名格式不正确"},
"PasswordInvalid": {"key": "PASSWORD_INVALID", "code": 400002, "message": "密码格式不正确"},
"PermissionDenied": {"key": "PERMISSION_DENIED", "status": 403, "code": 403001, "message": "没有权限"}
}
)
response CommonResponse (
codeType: int32
successCode: 0
errorCode: 9999
body: {"ok": "${ok}", "code": "${code}", "data": "${data}"}
errorBody: {"ok": "${ok}", "code": "${code}", "msg": "${message}"}
)
type TokenResponse {
Token string `json:"token"`
}
@server (
group: account
response: CommonResponse
errors: UsernameInvalid, PasswordInvalid, PermissionDenied
)
service errors-api {
@handler token
post /token returns (TokenResponse)
@response raw
@handler raw
get /raw returns (TokenResponse)
}

View File

@@ -45,10 +45,18 @@ func WrapMiddleware(middleware func(http.Handler) http.Handler) Middleware {
child.writer.(interface{ inheritCommit(int) }).inheritCommit(c.Status())
}
defer CleanupMultipart(r)
defer func() { c.values = child.values; c.encoder = child.encoder }()
if err := next(child); err != nil && !c.Responded() {
defer func() {
c.values = child.values
c.encoder = child.encoder
c.errorRegistry = child.errorRegistry
c.failure = child.failure
}()
if err := next(child); err != nil {
child.RecordError(err)
if !c.Responded() {
child.WriteError(err)
}
}
}))
if wrapped == nil {
panic("httpx: HTTP middleware returned nil handler")

View File

@@ -95,6 +95,8 @@ type Context struct {
responded bool
status int
encoder response.Encoder
errorRegistry *apierrors.Registry
failure *apierrors.APIError
}
// NewContext creates a transport-neutral GoOmni context.
@@ -133,6 +135,7 @@ func (c *Context) Raw() http.ResponseWriter { return c.writer }
func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context {
clone := NewContext(c.request.Clone(ctx), writer, c.params)
clone.encoder = c.encoder
clone.errorRegistry = c.errorRegistry
for key, value := range c.values {
clone.values[key] = value
}
@@ -279,8 +282,11 @@ func (c *Context) WriteError(err error) {
if errors.As(err, &bodyErr) {
err = bodyTooLarge()
}
apiErr := apierrors.From(err)
apiErr := c.ResolveError(err)
c.failure = apiErr
if writeErr := c.writeResult(response.Result{
BusinessCode: apiErr.BusinessCode,
Failed: true,
Status: apiErr.Status,
Code: apiErr.Code,
Message: apiErr.Message,
@@ -291,3 +297,28 @@ func (c *Context) WriteError(err error) {
_ = c.WriteResponse(http.StatusInternalServerError, nil, nil)
}
}
// SetErrorRegistry sets an application fallback. Route encoders can supply their
// own registry; clones retain both policies without any process-global lookup.
func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r }
func (c *Context) ResolveError(err error) *apierrors.APIError {
registry := c.errorRegistry
if provider, ok := c.encoder.(response.ErrorRegistryProvider); ok {
if scoped := provider.ErrorRegistry(); scoped != nil {
registry = scoped
}
}
return registry.Resolve(err)
}
// RecordError preserves failure semantics when a middleware commits buffered bytes.
func (c *Context) RecordError(err error) { c.failure = c.ResolveError(err) }
// 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 &copy
}

View File

@@ -7,7 +7,6 @@ import (
"sync/atomic"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
@@ -46,10 +45,10 @@ func (m *Metrics) Middleware() httpx.Middleware {
err := next(ctx)
status := ctx.Status()
if err != nil && !ctx.Responded() {
status = apierrors.From(err).Status
status = ctx.ResolveError(err).Status
}
m.requests.Add(1)
if err != nil || status >= 400 {
if err != nil || ctx.Failure() != nil || status >= 400 {
m.errors.Add(1)
}
if status >= 0 && status < len(m.statuses) {

View File

@@ -31,6 +31,7 @@ type IdempotencyConfig struct {
type replay struct {
fingerprint [32]byte
errorCode string
status int
headers http.Header
body []byte
@@ -150,8 +151,12 @@ 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
i.mu.Unlock()
headers.Set("Idempotency-Replayed", "true")
if errorCode != "" {
c.RecordError(apierrors.New(status, errorCode, ""))
}
return c.WriteResponse(status, headers, data)
}
if len(i.items) >= i.config.MaxEntries {
@@ -171,6 +176,9 @@ 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)
}
for k, values := range buffer.headers {
size += len(k)
for _, v := range values {
@@ -180,6 +188,9 @@ func (i *Idempotency) Middleware() httpx.Middleware {
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 {
saved.failed = false
saved.status = buffer.status
if failure := c.Failure(); failure != nil {
saved.errorCode = failure.Code
}
saved.headers = buffer.headers.Clone()
saved.body = append([]byte(nil), buffer.body.Bytes()...)
saved.size = size
@@ -202,6 +213,9 @@ func (i *Idempotency) Middleware() httpx.Middleware {
if buffer.status == 0 {
buffer.status = 200
}
if failure := child.Failure(); failure != nil {
c.RecordError(failure)
}
buffer.complete = true
return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes())
}

View File

@@ -73,10 +73,10 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware {
return func(ctx *httpx.Context) error {
started := time.Now()
err := next(ctx)
status := ctx.Status()
if err != nil && !ctx.Responded() {
status = apierrors.From(err).Status
ctx.WriteError(err)
}
status := ctx.Status()
duration := time.Since(started)
slow := settings.slowThreshold > 0 && duration >= settings.slowThreshold
attrs := []any{
@@ -87,6 +87,16 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware {
"durationMs", duration.Seconds() * 1000,
"clientIp", ctx.ClientIP(),
}
failure := ctx.Failure()
if failure == nil && err != nil {
failure = ctx.ResolveError(err)
}
if failure != nil {
attrs = append(attrs, "failed", true, "errorKey", failure.Code)
if failure.BusinessCode != nil {
attrs = append(attrs, "businessCode", failure.BusinessCode)
}
}
if slow {
attrs = append(attrs, "slow", true)
}

View File

@@ -15,6 +15,7 @@ import (
type StoredResponse struct {
Status int `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
Headers http.Header `json:"headers"`
Body []byte `json:"body"`
}
@@ -54,6 +55,9 @@ 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, ""))
}
return c.WriteResponse(response.Status, headers, response.Body)
}
completed := false
@@ -84,9 +88,15 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri
if buffer.status == 0 {
buffer.status = 200
}
if failure := child.Failure(); failure != nil {
c.RecordError(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
}
}
if err = finish(response); err != nil {
return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "outcome requires reconciliation").WithCause(err)

View File

@@ -49,6 +49,8 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
func WithErrors(catalog apierrors.Catalog) Option { return config.WithErrors(catalog) }
func WithResponse(settings response.Config) Option { return config.WithResponse(settings) }
func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) }

View File

@@ -22,3 +22,15 @@
环境:`go version go1.27.1 darwin/arm64`。记录时间:2026-09-23T18:17:16.897323+00:00。
独立 `TransportHTTP2` 的逐请求读写时限未实现,本次没有进行 Linux soak、代理长测或完整业务迁移验收。
## 远程验收
发布后两条独立远程验收均通过;各自使用全新 module cache,消费者依赖固定版本,无本地 replace。
- 发布标签:`v0.9.1-preview`;源码:`20e84e5ff86d872f42301cd3c256030fc41c8ce2`。
- module 校验和:`h1:yCqjVGv4rH8UN2M3WzjraNEfdEI6WJD2A64F4HFP0FI=`。
- Connector/HTTP 远程消费者执行全部专项 race 测试及真实 MySQL TLS 用例,无跳过;耗时 16.49 秒,详见 [Connector 远程记录](connectorRemote.json)。
- 从远程安装 CLI,生成消费者,验证标量、动态 JSON、上传/原文签名、下载、请求体上限和重生成保护;耗时 13.27 秒,详见 [CLI 远程记录](connectorCompatibilityRemote.json)。
- 两条验收脚本确认相同框架校验和;临时消费者、缓存、数据库容器与测试证书/凭据已清理。fixture 哈希与当前文件核对一致。
结果在 main 单独追加,已发布标签保持不可变。

View File

@@ -0,0 +1,31 @@
{
"passed": true,
"frameworkVersion": "v0.9.1-preview",
"frameworkSum": "h1:yCqjVGv4rH8UN2M3WzjraNEfdEI6WJD2A64F4HFP0FI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 13.27,
"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"
}
}

View File

@@ -0,0 +1,70 @@
{
"passed": true,
"frameworkVersion": "v0.9.1-preview",
"localSource": false,
"frameworkSum": "h1:yCqjVGv4rH8UN2M3WzjraNEfdEI6WJD2A64F4HFP0FI=",
"freshModuleCache": true,
"noLocalReplace": true,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 16.49,
"tests": [
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
"scripts/verifyConnector.py": "a0ba3fa29a4d1efcbed5dad82c09afb97d8ddecd904f20fee1e3aa74ec0f9e06"
}
}

View File

@@ -0,0 +1,32 @@
# v0.10.0-preview 统一错误目录验收
## 范围
项目定义错误目录,框架解析逻辑标识、HTTP 状态、对外业务码及公开提示;支持 HTTP 200 的业务失败,并贯通 `.api` 生成、响应、日志、指标、链路和错误重放。没有修改 eTax/ePay 业务代码,也没有使用业务服务器压测。
目录与响应模板分离;不同 App、路由可选择不同目录和协议。系统兜底不受 DefaultStatus 隐式影响,自定义 Encoder 继续控制最终输出。
## 本地回归
环境:go1.27.1 darwin/arm64。验收日期:2026-09-24。
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...` 全部通过,包含实际生成并编译的脚手架/契约消费者。
- 全量之后增加配置快照隔离与 Encoder 包装策略测试,完成 `go test -race -count=1 ./response ./config ./cmd/go-omni/apigen`。
- 本地/共享幂等错误元数据补充用例通过 `go test -race -count=1 ./app ./config ./httpx ./middleware/... ./metrics ./response ./telemetry`。
- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 与 CLI 校验 `examples/contracts/errors.api` 通过。
## 专项覆盖
- 多 App 隔离、目录复制、配置快照隔离、静态配置变更要求重启;并发解析及 WithCause 不互相污染。
- JSON/YAML 精确 int64、字符串/整数类型与范围、重复 key、非法状态/字段/映射冲突拒绝。重复对外业务码允许保留独立提示。
- HTTP 200 业务失败、403 单项覆盖、未知标识 500、内部原因不进入默认公开响应、已提交响应不追加、文本 ACK 由自定义 Encoder 输出。
- RawJSON 成功不包装、失败保持错误响应;嵌套相同/空目录保留已有策略,不同目录叠加明确拒绝。
- 访问日志记录最终状态和业务失败;指标计入 HTTP 200 失败,链路保留状态与失败标记。标准中间件、本地/共享幂等重放保留失败语义。
- 多文件导入、多个目录和不同码类型的路由作用域;200 成功/失败 OpenAPI anyOf、多错误同码的独立示例。
- 实际生成项目的无参数错误函数、全局中间件提前返回时的路由目录、unknown fallback、raw 成功/失败、手写文件重生成保护。
## 验证边界
共享幂等新增用例使用 JSON 序列化的内存替身,验证字段在持久化边界上保留;未为本特性运行外部 Redis 服务。没有进行 Linux soak、代理长测或完整业务迁移验收。业务项目仍需升级框架/CLI,并逐步将散落错误改为目录调用。
远程固定版本验收在发布后追加,已发布标签保持不可变。

82
response/catalog_test.go Normal file
View File

@@ -0,0 +1,82 @@
package response_test
import (
"encoding/json"
"strings"
"testing"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestCatalogHTTP200AndRawJSON(t *testing.T) {
c := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "invalid"}}}
cfg := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, ErrorMessage: "fallback", Body: map[string]any{"ok": "${ok}", "code": "${code}", "data": "${data}"}, ErrorBody: map[string]any{"failed": "${ok}", "code": "${code}", "msg": "${message}"}}
enc, err := response.WithErrors(c, response.MustNew(cfg))
if err != nil {
t.Fatal(err)
}
for _, encoder := range []response.Encoder{enc, response.RawJSON(enc)} {
p, err := encoder.Encode(response.Result{Status: 500, Err: apierrors.ByCode("INVALID")})
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err = json.Unmarshal(p.Body, &body); err != nil {
t.Fatal(err)
}
if p.Status != 200 || body["failed"] != false || body["code"] != float64(400002) || body["msg"] != "invalid" || body["data"] != nil {
t.Fatal(string(p.Body))
}
}
p, err := response.RawJSON(enc).Encode(response.Result{Status: 200, Data: map[string]bool{"value": true}})
if err != nil || strings.Contains(string(p.Body), "code") {
t.Fatal(string(p.Body), err)
}
for _, change := range []response.Config{
{CodeType: "string"},
{CodeType: "int32", SuccessCode: 0, ErrorCode: 9, Codes: map[string]any{"INVALID": 1}},
{CodeType: "int32", SuccessCode: 0, ErrorCode: 9, Messages: map[string]string{"INVALID": "different"}},
} {
if _, err := response.WithErrors(c, response.MustNew(change)); err == nil {
t.Fatal("accepted conflicting catalog mapping")
}
}
enc, err = response.WithErrors(c, response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if !r.IsError() || r.Status != 200 || r.BusinessCode != int64(400002) {
t.Fatal(r)
}
return response.Text(202, "ACK"), nil
}))
if err != nil {
t.Fatal(err)
}
p, err = enc.Encode(response.Result{Err: apierrors.ByCode("INVALID")})
if err != nil || p.Status != 202 || string(p.Body) != "ACK" {
t.Fatal(p, err)
}
}
func TestCatalogWrapperPreservesScope(t *testing.T) {
catalog := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
"invalid": {Code: "invalid", Message: "project message"},
}}
inner := response.MustWithErrors(catalog, response.Default())
for _, encoder := range []response.Encoder{inner, response.RawJSON(inner)} {
for _, outer := range []apierrors.Catalog{{}, catalog} {
bound, err := response.WithErrors(outer, encoder)
if err != nil {
t.Fatal(err)
}
payload, err := bound.Encode(response.Result{Err: apierrors.ByCode("invalid")})
if err != nil || payload.Status != 200 || !strings.Contains(string(payload.Body), "project message") {
t.Fatal(payload, err)
}
}
other := catalog.Clone()
other.Definitions["invalid"] = apierrors.Definition{Status: 409, Code: "other", Message: "other"}
if _, err := response.WithErrors(other, encoder); err == nil {
t.Fatal("stacked conflicting policies")
}
}
}

View File

@@ -162,7 +162,7 @@ func (f *Formatter) Config() Config {
func (f *Formatter) Value(r Result) map[string]any {
c := f.config
code, message := any(r.Code), r.Message
isError := r.Err != nil || r.Status >= 400
isError := r.IsError()
if isError {
if c.ErrorCode != nil {
code = c.ErrorCode
@@ -184,6 +184,9 @@ func (f *Formatter) Value(r Result) map[string]any {
if mapped, ok := c.Messages[r.Code]; ok {
message = mapped
}
if isError && r.BusinessCode != nil {
code, message = r.BusinessCode, r.Message
}
body := c.Body
if isError && c.ErrorBody != nil {
body = c.ErrorBody
@@ -206,7 +209,12 @@ func (f *Formatter) Value(r Result) map[string]any {
}
return value
}
func (f *Formatter) Encode(r Result) (Payload, error) { return JSON(r.Status, f.Value(r)) }
func (f *Formatter) Encode(r Result) (Payload, error) {
if err := f.validateError(r); err != nil {
return Payload{}, err
}
return JSON(r.Status, f.Value(r))
}
func normalizeCode(value any, kind string) (any, error) {
if kind == "string" {

View File

@@ -3,6 +3,8 @@ package response
import (
"encoding/json"
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// Result is the transport-independent input to an Encoder. Err is available for
@@ -12,8 +14,13 @@ type Result struct {
Code, Message, RequestID string
Data any
Err error
BusinessCode any
Failed bool
}
// IsError preserves failure semantics independently of the HTTP status.
func (r Result) IsError() bool { return r.Failed || r.Err != nil || r.Status >= 400 }
// Payload is a complete response. Encoders must be safe for concurrent use.
// Status and headers are intentionally part of the contract: callback protocols
// may acknowledge an error using a different HTTP status or media type.
@@ -47,14 +54,30 @@ func Default() Encoder { return defaultEncoder }
// RawJSON leaves successful data unwrapped. Errors use the selected error
// policy; callback protocols with custom acknowledgments should use EncoderFunc.
type rawJSONEncoder struct{ errors Encoder }
func (e *rawJSONEncoder) ErrorRegistry() *apierrors.Registry {
if p, ok := e.errors.(ErrorRegistryProvider); ok {
return p.ErrorRegistry()
}
return nil
}
func (e *rawJSONEncoder) Encode(r Result) (Payload, error) {
if r.IsError() {
return e.errors.Encode(r)
}
return JSON(r.Status, r.Data)
}
func RawJSON(errors Encoder) Encoder {
if errors == nil {
errors = Default()
}
return EncoderFunc(func(r Result) (Payload, error) {
if r.Err != nil || r.Status >= 400 {
return errors.Encode(r)
return &rawJSONEncoder{errors: errors}
}
return JSON(r.Status, r.Data)
})
func (e *rawJSONEncoder) ValidateErrors(catalog apierrors.Catalog) error {
if checker, ok := e.errors.(interface{ ValidateErrors(apierrors.Catalog) error }); ok {
return checker.ValidateErrors(catalog)
}
return nil
}

84
response/errors.go Normal file
View File

@@ -0,0 +1,84 @@
package response
import (
"fmt"
"reflect"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// ErrorRegistryProvider attaches an immutable error catalog to a route policy.
// Contexts and buffered clones use it before encoding or running middleware.
type ErrorRegistryProvider interface{ ErrorRegistry() *apierrors.Registry }
type errorEncoder struct {
next Encoder
registry *apierrors.Registry
}
func (e *errorEncoder) ErrorRegistry() *apierrors.Registry { return e.registry }
func (e *errorEncoder) Encode(r Result) (Payload, error) {
if r.Err != nil {
err := e.registry.Resolve(r.Err)
r.Status, r.Code, r.Message, r.BusinessCode, r.Failed = err.Status, err.Code, err.Message, err.BusinessCode, true
}
return e.next.Encode(r)
}
// WithErrors binds a catalog to an encoder without imposing a response shape.
// Built-in templates are checked for incompatible code types and conflicting
// legacy mappings. Custom encoders remain responsible for their wire contract.
func WithErrors(catalog apierrors.Catalog, next Encoder) (Encoder, error) {
registry, err := apierrors.NewRegistry(catalog)
if err != nil {
return nil, err
}
if next == nil {
next = Default()
}
if provider, ok := next.(ErrorRegistryProvider); ok && provider.ErrorRegistry() != nil {
if len(catalog.Definitions) == 0 {
return next, nil
}
if reflect.DeepEqual(registry.Catalog(), provider.ErrorRegistry().Catalog()) {
return next, nil
}
return nil, fmt.Errorf("response: cannot stack different error catalogs; choose one policy per scope")
}
if checker, ok := next.(interface{ ValidateErrors(apierrors.Catalog) error }); ok {
if err := checker.ValidateErrors(registry.Catalog()); err != nil {
return nil, err
}
}
return &errorEncoder{next: next, registry: registry}, nil
}
func MustWithErrors(catalog apierrors.Catalog, next Encoder) Encoder {
e, err := WithErrors(catalog, next)
if err != nil {
panic(err)
}
return e
}
func (f *Formatter) ValidateErrors(catalog apierrors.Catalog) error {
for key, d := range catalog.Definitions {
if err := f.validateError(Result{Code: key, Message: d.Message, BusinessCode: d.Code, Failed: true}); err != nil {
return err
}
}
return nil
}
func (f *Formatter) validateError(r Result) error {
if !r.IsError() || r.BusinessCode == nil {
return nil
}
normalized, err := normalizeCode(r.BusinessCode, f.config.CodeType)
if err != nil {
return fmt.Errorf("response: catalog %s code does not match codeType %s: %w", r.Code, f.config.CodeType, err)
}
if code, ok := f.config.Codes[r.Code]; ok && !reflect.DeepEqual(code, normalized) {
return fmt.Errorf("response: conflicting catalog/code mapping for %s", r.Code)
}
if msg, ok := f.config.Messages[r.Code]; ok && msg != r.Message {
return fmt.Errorf("response: conflicting catalog/message mapping for %s", r.Code)
}
return nil
}

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Install the released CLI in a fresh cache, then test its generated consumer.
The fixture tests project-owned error catalogs, HTTP 200 business failures,
route policies, generated constructors 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/errorCatalogRemote.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="goOmniErrorCatalogRelease-") 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/errors.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 error catalog generation, HTTP 200 failures and raw responses", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s",
"./cmd/go-omni/apigen", "-run", "^TestErrorCatalogConsumer$"], 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 == ["TestErrorCatalogConsumer"], 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", "duplicatePublicCodeDistinctMessages", "perErrorHTTPStatus",
"unknownErrorFallback", "causeNotExposed", "rawSuccessAndWrappedFailure",
"earlyMiddlewareCatalog", "handwrittenFilesPreserved", "generatedConsumerRaceTests",
"fixedRemoteModuleChecksum"],
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in
("scripts/verifyErrorCatalogRelease.py", "cmd/go-omni/apigen/errorCatalog_test.go",
"examples/contracts/errors.api",
"cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go")},
}
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote error catalog contract {args.version}; temporary files removed", flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
package telemetry_test
import (
"context"
"net/http/httptest"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
"git.yuntuo.hk/k2safe/go-omni/telemetry"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestCatalogFailureTraceWithHTTP200(t *testing.T) {
exporter := tracetest.NewInMemoryExporter()
runtime, err := telemetry.New(telemetry.Config{Service: "fixture", SampleRatio: 1, Exporter: exporter})
if err != nil {
t.Fatal(err)
}
defer runtime.Close()
app := omni.New(omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 123, Message: "invalid"}}}), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999}))
defer app.Close()
app.Use(runtime.Middleware())
app.GET("/", func(c *omni.Context) error { c.WriteError(apierrors.ByCode("INVALID")); return nil })
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != 200 {
t.Fatal(w.Code)
}
if err := runtime.Flush(context.Background()); err != nil {
t.Fatal(err)
}
spans := exporter.GetSpans()
if len(spans) != 1 || spans[0].Status.Code != codes.Error {
t.Fatal(spans)
}
seenStatus, seenFailure := false, false
for _, attr := range spans[0].Attributes {
if attr.Key == "http.response.status_code" && attr.Value.AsInt64() == 200 {
seenStatus = true
}
if attr.Key == "omni.request.failed" && attr.Value.AsBool() {
seenFailure = true
}
}
if !seenStatus || !seenFailure {
t.Fatal(spans[0].Attributes)
}
}

View File

@@ -4,7 +4,10 @@ package telemetry
import (
"context"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"math"
"net/http"
"time"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"go.opentelemetry.io/otel/attribute"
@@ -15,9 +18,6 @@ import (
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
"math"
"net/http"
"time"
)
type Config struct {
@@ -99,15 +99,22 @@ func (r *Runtime) Middleware() httpx.Middleware {
defer func() {
status := c.Status()
if err != nil && !c.Responded() {
status = apierrors.From(err).Status
status = c.ResolveError(err).Status
}
p := recover()
if p != nil {
status = 500
}
attrs := []attribute.KeyValue{attribute.String("http.request.method", method), attribute.String("http.route", route), attribute.Int("http.response.status_code", status)}
failure := c.Failure()
if failure == nil && err != nil {
failure = c.ResolveError(err)
}
if failure != nil {
attrs = append(attrs, attribute.Bool("omni.request.failed", true), attribute.String("omni.error.key", failure.Code))
}
span.SetAttributes(attrs...)
if status >= 500 {
if status >= 500 || (failure != nil && status < 400) {
span.SetStatus(codes.Error, "request failed")
}
r.requests.Add(ctx, 1, metric.WithAttributes(attrs...))