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