Compare commits
2 Commits
v0.9.1-pre
...
v0.10.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| ebfd279f9a | |||
| bb2caefbb4 |
25
API.md
25
API.md
@@ -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` 提供错误目录与路由关联。目录解析已定义的错误,不自动推断数据库或其他未声明错误的业务含义,也不穷举所有运行时错误。
|
||||
|
||||
## 鉴权:声明与路由注册一起变化
|
||||
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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、事务,不注册全局驱动配置。
|
||||
|
||||
73
README.md
73
README.md
@@ -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)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
51
RELEASE.md
51
RELEASE.md
@@ -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)。
|
||||
|
||||
@@ -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 的成功输出保持原样,失败继续交给所选错误编码器。
|
||||
|
||||
@@ -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
178
app/errorCatalog_test.go
Normal 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, ©); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Response = ©
|
||||
}
|
||||
s.records[key] = r
|
||||
return nil
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||
type ErrorDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
Catalog string `json:"catalog,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
}
|
||||
type AuthDefinition struct {
|
||||
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
||||
|
||||
118
cmd/go-omni/apigen/errorCatalog.go
Normal file
118
cmd/go-omni/apigen/errorCatalog.go
Normal 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)
|
||||
}
|
||||
205
cmd/go-omni/apigen/errorCatalog_test.go
Normal file
205
cmd/go-omni/apigen/errorCatalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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`},
|
||||
|
||||
@@ -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
|
||||
|
||||
92
cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go
vendored
Normal file
92
cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -25,18 +26,19 @@ type TLSConfig struct {
|
||||
|
||||
// Config controls the default runtime behavior of an App.
|
||||
type Config struct {
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Log logx.Config `json:"log"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
75
config/errorCatalog_test.go
Normal file
75
config/errorCatalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
133
errors/README.md
Normal 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
217
errors/catalog.go
Normal 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 ©
|
||||
}
|
||||
|
||||
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
100
errors/catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
// APIError is the public error shape used by handlers and middleware.
|
||||
// Cause is retained for logging and unwrapping, but is never serialized.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
Cause error
|
||||
Status int
|
||||
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)
|
||||
|
||||
36
examples/contracts/errors.api
Normal file
36
examples/contracts/errors.api
Normal 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)
|
||||
}
|
||||
@@ -45,9 +45,17 @@ 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() {
|
||||
child.WriteError(err)
|
||||
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 {
|
||||
|
||||
@@ -88,13 +88,15 @@ func RoutePattern(ctx context.Context) string {
|
||||
// only standard-library HTTP types, so it remains usable when the network
|
||||
// implementation is replaced.
|
||||
type Context struct {
|
||||
request *http.Request
|
||||
writer http.ResponseWriter
|
||||
params map[string]string
|
||||
values map[string]any
|
||||
responded bool
|
||||
status int
|
||||
encoder response.Encoder
|
||||
request *http.Request
|
||||
writer http.ResponseWriter
|
||||
params map[string]string
|
||||
values map[string]any
|
||||
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,15 +282,43 @@ 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{
|
||||
Status: apiErr.Status,
|
||||
Code: apiErr.Code,
|
||||
Message: apiErr.Message,
|
||||
RequestID: c.RequestID(),
|
||||
Err: err,
|
||||
BusinessCode: apiErr.BusinessCode,
|
||||
Failed: true,
|
||||
Status: apiErr.Status,
|
||||
Code: apiErr.Code,
|
||||
Message: apiErr.Message,
|
||||
RequestID: c.RequestID(),
|
||||
Err: err,
|
||||
}); writeErr != nil && !c.responded {
|
||||
// An invalid custom encoder must not recurse or leak a different envelope.
|
||||
_ = 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 ©
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
type StoredResponse struct {
|
||||
Status int `json:"status"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
Status int `json:"status"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
type IdempotencyRecord struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
@@ -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)
|
||||
|
||||
2
omni.go
2
omni.go
@@ -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) }
|
||||
|
||||
@@ -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 单独追加,已发布标签保持不可变。
|
||||
|
||||
31
performance/connectorCompatibilityRemote.json
Normal file
31
performance/connectorCompatibilityRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
70
performance/connectorRemote.json
Normal file
70
performance/connectorRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
performance/errorCatalogAcceptance.md
Normal file
32
performance/errorCatalogAcceptance.md
Normal 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
82
response/catalog_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 JSON(r.Status, r.Data)
|
||||
})
|
||||
return &rawJSONEncoder{errors: errors}
|
||||
}
|
||||
|
||||
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
84
response/errors.go
Normal 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
|
||||
}
|
||||
78
scripts/verifyErrorCatalogRelease.py
Normal file
78
scripts/verifyErrorCatalogRelease.py
Normal 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()
|
||||
51
telemetry/errorCatalog_test.go
Normal file
51
telemetry/errorCatalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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...))
|
||||
|
||||
Reference in New Issue
Block a user